Android XR

Melhore as experiências imersivas com mecanismos 3D no Jetpack Compose para XR

Alto-falantes

Sobre

Sobre a palestra

Saiba como integrar mecanismos de jogos 3D (Godot e Unity) com o Jetpack Compose em uma única base de código para dispositivos móveis e Android XR. Vamos mostrar como incorporar cenas em 3D em contêineres espaciais e configurar a comunicação entre o Kotlin e o mecanismo, além de mostrar como a NBA criou a experiência de basquete ao vivo no Android XR.

Resumo

  • A extensão OpenXR Spatial Containers permite que os mecanismos 3D compartilhem espaço com a interface nativa do Android. Em vez de assumir o controle do skybox, os mecanismos em tempo real são executados em um volume 3D gerenciado, ao lado dos painéis espaciais do Jetpack XR.
  • Os desenvolvedores podem incorporar mecanismos em tempo real diretamente em aplicativos do Jetpack Compose como bibliotecas AAR. Essa abordagem permite que o Android nativo processe a lógica de negócios e a interface enquanto o mecanismo renderiza componentes 3D.
  • A incorporação do Godot requer uma dependência AAR e uma regra de build do AAPT para manter os recursos ocultos. Uma subclasse GodotPlugin processa a comunicação bidirecional com o Kotlin.
  • Os loops de interface do Compose precisam ficar separados dos loops de marcação contínua do mecanismo. O encaminhamento cego de atualizações do mecanismo aciona a recomposição constante e causa atrasos na IU.
  • O app da NBA integra o Unity como uma biblioteca incorporada em um processo separado usando AIDL para comunicação entre processos, protegendo o app host contra falhas.

Recursos

Gemini Notebook

Tópicos

  • android-xr
  • jetpack-compose
  • spatial-containers
  • openxr
  • godot
  • união

Transcrição

Introdução ao Android XR

  • Olá!

  • Meu nome é Luke Hopkins.

  • Sou engenheiro de relações com desenvolvedores na equipe do Android XR.

  • Na palestra de hoje, vamos falar sobre como incorporar mecanismos de jogo, como Godot e Unity, ao seu Projeto do Android.

  • Depois, Josh e Rodrigo, da NBA e da Beyond Sports, vão se juntar a mim para falar sobre como estão implementando o Unity como uma biblioteca no aplicativo da NBA.

  • Nesta sessão, vamos falar sobre como integrar o Godot ao seu aplicativo do Jetpack Compose. Depois, Josh e Rodrigo vão falar sobre o aplicativo da NBA.

  • O Android XR é uma extensão do ecossistema Android, que expande uma ampla variedade de formatos para levar as experiências que você conhece e adora a uma nova dimensão.

  • Tornamos intuitivo o processo de trazer seus apps XR e Android para esse novo espaço, e estamos aproveitando as tecnologias Android que você já usa.

  • O Android XR é uma plataforma unificada projetada para uma ampla variedade de dispositivos.

  • Os dispositivos Android XR oferecem diferentes tipos de experiências.

  • Temos experiências de realidade aumentada para áudio e óculos com tela, além de experiências imersivas para headsets XR e óculos XR com fio.

  • Hoje, vamos falar sobre como criar experiências imersivas para óculos XR com fio e headsets XR.

  • Para criar experiências imersivas, use o SDK do Jetpack para XR ou o OpenXR com um mecanismo em tempo real.

  • Oferecemos suporte ao Unity, Godot e Unreal para que você possa criar experiências imersivas para Android, não importa qual ferramenta use.

  • Nesta palestra, vou explicar como usar a API SceneCore do Jetpack, o Godot e uma nova extensão do OpenXR para tornar isso possível.

  • Aqui, você pode ver o Godot em execução lado a lado com os painéis espaciais do Jetpack.

  • Observe como o Godot é renderizado como um conteúdo espacial, fornecendo um volume 3D ao lado da interface 2D do Compose em um contêiner espacial.

  • Essa abordagem permite que os desenvolvedores executem simulações de física e sombreadores personalizados usando o mecanismo em tempo real que preferirem, além de aplicar práticas de desenvolvimento nativas do Android ao restante do aplicativo.

  • Com o Android XR e o XREAL AURA, você também pode criar experiências como essa.

  • Nesta palestra, vamos ensinar como criar essa experiência usando o Godot e o Android Studio. Depois, nossos parceiros da NBA e da Beyond Sports vão explicar como criaram essa experiência usando o Unity como uma biblioteca.

Contêineres espaciais

  • Historicamente, criar uma experiência de XR com o OpenXR significava criar uma experiência que renderiza cada pixel.

  • Isso significa que, se um desenvolvedor precisar mostrar uma segunda atividade, não poderá ter as duas ao mesmo tempo.

  • Para resolver isso, o Khronos, o grupo de trabalho do OpenXR, em colaboração com vários líderes do setor, lançou a extensão de contêineres espaciais OpenXR.

  • Pense nos contêineres espaciais como OpenXR em uma caixa.

  • Em vez de o mecanismo ser o proprietário de todo o skybox, ele recebe um volume 3D gerenciado pelo sistema que pode coexistir com os painéis espaciais de XR do Jetpack, outros contêineres espaciais e atividades padrão do Android.

  • Digamos que você queira usar o Android XR para mostrar dois painéis espaciais ao mesmo tempo enquanto executa um jogo imersivo.

  • Você renderiza tudo no mecanismo, o que possibilita, nesses casos de uso, ter o editor do Godot e um contêiner espacial em execução ao mesmo tempo.

  • A partir do nível 4 da API Spatial, o Google implementou os contêineres espaciais no Android XR.

  • Os contêineres espaciais oferecem dois modos de exibição importantes.

  • O primeiro é o modo limitado.

  • Isso fornece um volume 3D definido com várias prevenções integradas.

  • O usuário pode pegar o contêiner espacial, girar, posicionar, minimizar, e ele é compatível com a projeção de profundidade estéreo, oferecendo aquela janela mágica ou o efeito de pop-out 3D que você tem ao cortar as bordas do contêiner.

  • O segundo é o modo ilimitado.

  • Se o usuário quiser uma imersão total, seu app poderá solicitar uma transição perfeita do modo limitado para o modo de Espaço Ampliado e, em seguida, voltar para o modo limitado.

  • Com a adição de contêineres espaciais, o Android XR abre as portas para ideias novas e amplas.

  • Digamos que você queira usar a interface do Android no seu jogo ou criar um sombreador personalizado para mostrar conteúdo em 3D no seu aplicativo.

  • Antes da introdução dos contêineres espaciais, era impossível ter o Compose e um mecanismo de jogo em execução ao mesmo tempo.

  • E, ao analisar o desenvolvimento de aplicativos em mecanismos em tempo real, também vivemos em dois paradigmas diferentes.

  • Para desenvolvedores Android nativos, o ambiente de execução do aplicativo é criado com base no Jetpack Compose.

  • Ele foi projetado para uma interface rápida e reativa, eficiência da bateria, layouts dinâmicos, acessibilidade do sistema e entrada de texto integrada.

  • Ele fica inativo quando nada acontece e é previsível.

  • Para desenvolvedores de mecanismos de jogos, o domínio é a renderização em tempo real, em que ferramentas como Godot e Unity se destacam.

  • Eles não ficam apenas esperando a interação do usuário.

  • Eles executam um loop contínuo de marcação e renderização.

  • Isso pode ser em frame rates de 60, 90, 100 por segundo.

  • Eles fazem cálculos de física, sombreadores complexos, animações esqueléticas, áudio espacial e tudo isso com um desempenho incrível.

Abordagens para integrar mecanismos 3D

  • E se você quiser o melhor dos dois mundos?

  • Antes, os desenvolvedores precisavam implementar uma dessas duas abordagens.

  • A opção A é reescrever todo o aplicativo em um mecanismo real, como o Godot ou o Unity.

  • Descartar a base de código atual significa perder grande parte da IU nativa do Compose, reimplementar o texto e a navegação básicos e escrever wrappers em APIs do Android, como a conversão de texto em voz.

  • A opção B é criar um pipeline de renderização de gráficos 3D de baixo nível do zero no app nativo usando APIs Vulkan e OpenXR brutas.

  • Em vez de fornecer recursos de produtos, você está criando seu próprio mecanismo proprietário.

  • Por isso, hoje vou mostrar como aproveitar ao máximo o Godot e o Unity como bibliotecas.

  • E com isso, a adição de contêineres espaciais, agora você pode ter o melhor dos dois mundos.

  • Para isso, podemos incorporar um mecanismo em tempo real diretamente em uma biblioteca no aplicativo do Jetpack.

  • Você mantém a estrutura do app Android de produção, a lógica de negócios e a renderização perfeita de conteúdo espacial 3D no aplicativo.

  • Isso também significa que você pode ter um APK para todos os formatos.

  • Se você não quiser mostrar conteúdo em 3D, pode optar por não carregar a biblioteca na atividade ou no fragmento e manter sua base de código limpa, além de oferecer suporte a muitos dispositivos.

  • A filosofia é simples: desacoplar o aplicativo e transformar o mecanismo em tempo real em uma biblioteca.

  • Em vez de escolher entre um app Android ou um mecanismo, você trata o mecanismo como uma biblioteca de software incorporada, também conhecida como dependência AAR.

  • Você pode extrair isso diretamente para o build do Gradle, como qualquer outra biblioteca.

  • O código nativo do Android continua sendo o cérebro absoluto do aplicativo que hospeda os componentes 3D.

  • O Jetpack Compose XR faz o que sabe fazer de melhor: renderizar uma interface fluida e acessível nativa e gerenciar o estado do app.

  • Quando um elemento 3D é necessário, o app hospeda uma visualização de renderização incorporada que é renderizada pelo próprio mecanismo de jogo.

  • Hoje, vamos analisar em detalhes como isso funciona no Godot.

  • Vou começar com a mecânica de incorporação e estabelecimento de comunicação bidirecional entre o Kotlin e o GDScript.

  • Em seguida, vamos analisar a nova extensão OpenXR, o contêiner espacial, que vai realmente desbloquear a nova geração de multitarefa e layout espacial.

  • Vamos dar uma olhada rápida na demonstração que mostrei antes.

  • Eu fiz isso usando o Godot e o Android Studio.

  • Este clipe mostra o Jetpack Compose para XR renderizando painéis espaciais de interface 2D nativos para nosso placar, notificações e destaques.

  • Além disso, o Godot está renderizando uma simulação de uma partida de futebol.

Comunicação entre Kotlin e Godot

  • Aqui, você pode ver uma análise detalhada de como nos comunicamos do Kotlin para o GDScript.

  • Para isso, o Godot oferece uma arquitetura de plug-in.

  • Você escreve uma classe Kotlin leve que estende um GodotPlugin.

  • Essa classe serve como uma interface entre a JVM e o ambiente de execução C++ do Godot.

  • Por isso, as interações entre o app e o Godot são muito leves.

  • A arquitetura do Godot oferece suporte à comunicação bidirecional entre o Godot e o Kotlin.

  • Quando um evento de jogo ocorre no Godot, como um gol ou uma falta, o Godot pode invocar diretamente as funções do Kotlin e atualizar a interface do Compose em tempo real.

  • Quando o usuário interage com o player de mídia, por exemplo, para pausar ou selecionar diferentes destaques, o Kotlin emite um sinal de volta para o Godot, e as ações correspondentes do mecanismo de jogo acontecem.

  • Depois que o plug-in do Godot é cruzado, o desempenho e o gerenciamento simplesmente seguem as linhas do domínio.

  • Aplique as práticas recomendadas padrão para apps Android e projetos do Godot.

  • Agora vamos analisar a arquitetura.

  • Vamos ver como conectar isso ao Godot em um app Android.

  • O ambiente de execução do mecanismo Godot é distribuído pelo Maven como um AAR.

  • A integração ao build segue as práticas padrão do Android.

  • Há uma etapa importante que você precisa conhecer imediatamente, que é o que chamo de armadilha de recursos.

    androidResources {
         ignoreAssetsPattern = "!.svn:!.git:!.gitignore:!.ds_store:!*.scc:<dir>_*:!CVS:!*~:!android:!export_presets.cfg"
     }
  • Em um projeto do Godot, as cenas e os arquivos de configuração do projeto ficam juntos.

  • Ao incorporar o Godot, o projeto dele é adicionado diretamente à pasta de recursos.

  • Isso significa que todas as malhas 3D importadas, texturas compactadas e texturas compiladas são armazenadas como um artefato de ambiente de execução em um arquivo oculto chamado .godot.

  • Por padrão, o Android remove essa pasta ao fazer o build, ignorando qualquer diretório com um ponto.

  • Portanto, se você sincronizar e criar seu app normalmente, o APK será criado, mas vai falhar e carregar porque esses elementos não foram compilados no APK.

  • Para corrigir isso, basta substituir explicitamente as opções do AAPT no build.gradle e permitir que os pontos iniciais aconteçam no Godot.

  • Depois disso, você pode executar a cena do Godot uma vez no editor do Godot e, em seguida, criar todo o resto no Android Studio.

  • Para oferecer suporte a contêineres espaciais e ao OpenXR mais recente, recomendo usar o Godot 4.8 e versões mais recentes, as extensões de fornecedor do Godot 5.2 e o carregador OpenXR do 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")

Como renderizar o Godot no Jetpack Compose

  • Depois que os recursos são empacotados, como o Godot e o Compose realmente renderizam?

  • A biblioteca Godot fornece um método de fábrica chamado 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 }
        )
    }
  • Isso retorna um SurfaceView que o Godot renderiza.

  • Então, você envolve isso em uma função combinável, e o Godot agora pode ser dimensionado, preenchido e colocado em qualquer lugar dentro de um painel de entidade.

    Subspace {
            SpatialPanel(
                modifier = SubspaceModifier
                    .width(960.dp)
                    .height(540.dp)
                    .offset(z = (-200).dp)
                    .movable()
    ,
        ) {
            GodotView(
                godotHost = godotHost,
                modifier = Modifier.fillMaxSize(),
            )
        }
    }
  • O mecanismo em si não pode gerenciar o 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)
           }
       }
    
    }
  • Portanto, sua atividade precisa implementar a interface GodotHost.

  • Isso vai garantir que todos os ciclos de vida que estão acontecendo na sua atividade, em pausa, em retomada, sejam enviados para o próprio mecanismo Godot, que agora está ciente do ciclo de vida.

  • Em seguida, precisamos adicionar nosso app nativo e nosso mecanismo para que eles se comuniquem sem problemas.

  • Para fazer isso, podemos usar a interface GodotHost.

    class MainActivity : ComponentActivity(), GodotHost {
        override fun getHostPlugins(engine: Godot): Set<GodotPlugin> =
            setOf(SoccerGodotPlugin(engine))
    }
  • Podemos definir o plug-in do Godot e, aqui, garantir que, quando o Godot for iniciado, o plug-in seja registrado pelo Compose.

  • Agora que o plug-in está registrado, vamos configurar o Android para se comunicar emitindo um sinal para o Godot.

  • No código Kotlin, vamos criar uma instância de GodotPlugin.

  • Vamos fazer com que nossa atividade registre o plug-in na biblioteca do Godot.

  • Em seguida, no Kotlin, criamos o GodotPlugin, registramos o nome dele e informamos à Godot todos os indicadores que queremos usar nesse pacote.

    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),
        )
    
    }
  • Em seguida, temos um script do Godot, em que é possível acessar a instância desse 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)
  • Podemos ver o que está registrado e, em seguida, conectar as funções aos sinais que registramos.

  • Depois de chamar algo como emitSignal, você pode transmitir isso do seu código Kotlin, e ele vai direto para o Godot.

    class SoccerGodotPlugin(godot: Godot) : GodotPlugin(godot) {
    
        fun sendCommandToGodot(command: String) {
            emitSignal("match_command", command)
        }
    }
  • Para que o Godot se comunique com o Kotlin, digamos que um gol aconteça, podemos chamar uma função do Kotlin diretamente do script do Godot, usando apenas a instância do plug-in.

    func _on_goal_scored(minute: int, home: int, away: int) -> void:
    
        if soccer_plugin:
            soccer_plugin.onMatchEvent("GOAL", minute, home, away)
  • Primeiro, anote a função Kotlin com 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)
            }
        }
  • O mecanismo processa o marshalling da JNI em segundo plano.

  • Depois, podemos chamar a função diretamente do Godot Script.

  • Uma dica rápida para lembrar ao usar isso é que UsedByGodot exige uma correspondência exata de string.

  • Portanto, se o script do Godot chamar onMatchEvent, o nome em Kotlin precisará ser exatamente o mesmo, para que não haja conversão automática de letras concatenadas ou snake_case.

  • Portanto, você só precisa garantir que os dois métodos sejam exatamente iguais.

  • Com o plug-in conectado, a parte mais importante desta palestra é o que chamo de modelo mental.

  • É aí que os mecanismos de jogo e o Jetpack Compose operam em duas arquiteturas fundamentalmente opostas.

  • Um mecanismo de jogo opera em um tick de frame contínuo.

  • Mesmo que você não mude nada, o processo de tick do mecanismo de jogo acontece 60, 90, 100 vezes por segundo, oferecendo uma renderização em tempo real rica e suave.

  • E mecanismos como o Godot usam os ciclos de GPU e CPU de forma eficaz.

  • O Jetpack Compose, por outro lado, lida com as coisas de forma reativa e é orientado por estado.

  • E quando um app não está mudando, o Compose literalmente continua, consumindo 0 CPU.

  • Se você quiser conectar esses dois ambientes de execução sem uma estratégia clara, essas filosofias tendem a entrar em conflito.

  • Vamos ver como contornar esse problema com o que chamo de vazamento de tick.

  • Aqui temos uma experiência em 3D, digamos, o cronômetro do placar.

  • No script do mecanismo, há uma variável de timer que muda continuamente.

  • Se você encaminhar essa variável para o Compose, digamos, 90 vezes por segundo, vai entrar nesse loop de recomposição 90 vezes por segundo.

  • Isso significa que o Compose vai reavaliar os elementos que podem ser compostos, medir novamente os layouts e redesenhar as árvores de IU 90 vezes por segundo.

  • Você vai notar quedas de frames, atrasos na interface e vai atingir o limite térmico rapidamente.

  • Portanto, a regra de ouro é nunca deixar que o loop contínuo do mecanismo dite o loop reativo da interface.

  • Neste exemplo, em vez de fazer o Godot atualizar o timer do placar continuamente, eu apenas garanto que o Android extraia esses dados uma vez por segundo quando necessário.

    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 fim, sempre se lembre do limite de linhas de execução.

  • Um loop do mecanismo de jogo Godot é executado continuamente na própria linha de execução de renderização.

  • As interfaces do Android precisam ser executadas na linha de execução principal do Android.

  • Portanto, sempre garanta que o plug-in do Kotlin envie eventos usando Dispatchers.Main ou primitivos de estado seguro antes de atualizar os estados do modelo de visualização.

    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
           )
       }
  • Ao manter o trabalho de alta frequência no mecanismo e a semântica discreta no Compose, é possível ter janelas de visualização 3D extremamente suaves sem comprometer a duração da bateria, a acessibilidade ou o desempenho do app nativo.

  • Agora, vamos ver como adicionar contêineres espaciais ao seu projeto do Godot.

  • Primeiro, vamos configurar o app para ser de Espaço Ampliado, ativar os contêineres espaciais com as dicas de limite e definir a biblioteca do carregador de XR no arquivo de manifesto do 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="" />
  • O Godot tem as próprias configurações de projeto de XR, em que posso ativar o OpenXR, os sombreadores de XR e os contêineres espaciais, apenas pelo editor.

  • E isso é basicamente tudo o que você precisa fazer para ativar os contêineres espaciais no Godot.

  • Agora que você já sabe como usar o Godot com o Android XR, os contêineres espaciais e tudo o que foi desenvolvido, as práticas recomendadas para usar contêineres espaciais, além de painéis espaciais e JXR, recomendamos que você confira mais informações no site para desenvolvedores.

  • Acabamos de mostrar um exemplo de como usar contêineres espaciais no Godot Engine e no Android XR. Tenho certeza de que muitos de vocês gostariam de ouvir um desenvolvedor que está trabalhando ativamente com contêineres espaciais para a experiência no Android XR.

  • Agora, quero convidar Josh e Rodrigo da NBA, que é um dos primeiros parceiros a usar os contêineres espaciais no aplicativo.

Visão geral da NBA Court View

  • Obrigado, Luke.

  • Olá!

  • Meu nome é Josh e sou desenvolvedor Android na NBA.

  • Estou aqui hoje com meu colega, Rodrigo Sanchez, desenvolvedor de software Unity na Beyond Sports.

  • Hoje, vamos mostrar como usamos o Unity as a Library para criar o Court View para o app da NBA.

  • Na NBA, estamos sempre buscando novas experiências para nossos fãs.

  • Por isso, em parceria com o Google e a Beyond Sports, temos o prazer de apresentar o Court View.

  • A Court View oferece recriações em 3D dos jogos da NBA, permitindo que os assinantes do League Pass assistam de novas perspectivas que não são possíveis com o vídeo de transmissão normal.

  • Redimensione e gire a quadra, colocando-a onde quiser no seu espaço.

  • Mova-se ao redor dele e assista ao jogo de ângulos únicos.

  • Enquanto isso, você pode usar o app da NBA normalmente, desde conferir as estatísticas de sincronização até assistir à transmissão ao vivo.

  • Como tudo isso é configurado?

  • A visualização da quadra é renderizada no Unity e consumida pelo nosso app Android usando o Unity como uma biblioteca.

  • Em seguida, produzimos um único Android Archive, hospedado no Maven Central, que o app da NBA pode usar como uma única dependência.

  • Isso nos permitiu dividir o trabalho entre o lado do Android e o do Unity.

  • Assim, cada um de nós pode se concentrar no que sabe melhor e trabalhar em paralelo para acelerar o desenvolvimento geral.

  • Por exemplo, enquanto nossa equipe trabalhava na experiência de Espaço Ampliado no Compose, a equipe do Unity pôde se concentrar no dimensionamento e no layout da quadra 3D.

  • Agora, vou passar a palavra para o Rodrigo, que vai falar mais sobre o projeto do Unity e como eles trabalharam com o Unity as a Library.

Como integrar o Unity como uma biblioteca

  • Atenciosamente, Josh.

  • Nós da Beyond Sports trabalhamos com a NBA há alguns anos para representar visualizações em 3D com dados reais de partidas.

  • Com esse projeto, adaptamos nossa simulação 3D para ser compatível com o Android XR.

  • Nossa simulação é criada no mecanismo Unity com C#.

  • Exportamos a simulação para o Android com o Unity como uma biblioteca e criamos uma camada de comunicação ao lado dela usando o Kotlin.

  • Antes de agrupar esses dois módulos como uma única dependência do Maven para a NBA incluir no aplicativo.

  • Na tela, você pode ver um vídeo curto da mesma simulação que o Josh mostrou antes, mas sendo executada no editor.

  • Para isso, usamos o pacote de extensões do Android XR para Unity.

  • Vou explicar o processo.

  • Portanto, antes de criar no Unity, verifique se você selecionou as configurações e os plug-ins certos.

  • Baixe o pacote de gerenciamento de plug-ins de XR do Unity e o pacote de extensões do Android XR para Unity do Google.

  • Configure-os nas configurações do XR Plug-in Management para usar o provedor de extensões do Android XR OpenXR.

  • Para nosso caso de uso, queríamos usar os contêineres espaciais. Por isso, ativamos esse recurso e desativamos a XR imersiva nas configurações de gerenciamento de sessão.

  • Não usamos nenhum pacote de entrada de XR, já que todas as entradas são processadas de forma nativa, fora do Unity.

  • Em seguida, nas configurações do player, defina o projeto para usar um nível da API mínimo de 24 ou mais recente, e o ponto de entrada do aplicativo precisa ser definido como a nova Game Activity.

  • A opção "Atividade" precisa ficar desmarcada.

  • Como só queremos criar para ARM64, selecionamos essa arquitetura de destino. Na seção de resolução, ativamos a opção "Run in Background" para que você possa realizar várias tarefas com outros apps e manter a simulação 3D em execução em segundo plano.

  • Também ativamos a atividade redimensionável, porque ela é necessária para renderizar caixas de diálogo, como solicitações de permissão do sistema.

  • Por fim, recomendamos que os apps Android XR sejam renderizados usando o Universal Render Pipeline e a API Vulkan Graphics.

  • No recurso URP, o HDR e o pós-processamento precisam estar desativados.

  • Depois de concluir todas essas etapas, você poderá criar um build.

  • Vá para a janela de build e, se estiver usando o Unity 6.1 ou mais recente, selecione o perfil de build do Android XR, que permite separar as configurações se você também criar para dispositivos móveis Android.

  • Antes de criar, ative a exportação do projeto.

  • Depois que o Unity terminar de criar, você terá um projeto do Android funcional que pode ser montado no Android Studio e executado em um dispositivo como qualquer outro APK do Unity.

  • No Android Studio, transforme o build exportado em uma biblioteca para ser usada por outro aplicativo.

    // 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")
    }
    }
  • No nosso projeto, temos quatro módulos diferentes: o módulo principal do APK para testar como um marcador de posição de simulação para o aplicativo de encapsulamento da NBA, o módulo da biblioteca do Unity que contém nosso build exportado, um módulo nativeCommunications que contém nossa camada de comunicação que criamos entre as atividades e um módulo packageExporter, que usamos para mesclar nossos AARs em um único pacote para entregar ao Maven.

  • Para mesclar vários AARs, use a biblioteca combinada do Android ou métodos mais antigos da comunidade, como o Fat AAR.

  • Para manter a estrutura e a comunicação simples, mantenha o Unity e a atividade principal em execução no mesmo processo.

  • Optamos por não fazer isso e decidimos separar a atividade do Unity em um processo próprio como uma proteção para o caso de a parte do Unity falhar. Assim, o app da NBA não é afetado e pode lidar com o fechamento do Unity sem falhar.

  • A decisão nos força a adotar um sistema de comunicação entre processos mais robusto.

  • Como as atividades estão em processos diferentes, não há memória compartilhada entre elas, e muitos métodos comuns de IPC do Android não funcionam ou não atendem aos requisitos.

  • Usamos a linguagem de definição de interface do Android (AIDL, na sigla em inglês) para estabelecer a comunicação entre a atividade principal do NBA e nossa atividade incorporada do Unity.

  • Em seguida, a comunicação entre o Android e o Unity também é baseada em interfaces, usando o AndroidJavaProxy integrado no Unity para registrar uma interface Kotlin em uma implementação de interface C#.

  • Nosso módulo de comunicação se concentra principalmente no que chamamos de ponte de atividade.

    // 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) }
    }
    }
  • As duas atividades têm acesso à ponte de atividades e aos métodos utilitários dela.

  • A ponte contém uma referência a um serviço que implementa as interfaces AIDL que definimos e também oferece uma maneira fácil para as atividades se vincularem ao processo, desculpe, ao serviço e gerencia internamente o ciclo de vida dele.

  • Para iniciar uma simulação, use um callback em que o ID da partida é transmitido como parâmetro.

  • Em seguida, a ponte o transmite pela AIDL e, depois, para o app Unity, em que o C# recebe um comando e começa a configurar a cena.

    public class NativeInterfaceListener : AndroidJavaProxy {
    [UsedImplicitly]
    public void startMatch(string matchID) {
       UnityThreadHelper.InvokeOnMain(() => _internalAPI.StartMatch(matchID));
    }
    }
  • Precisamos esperar que o Unity esteja ativo e pronto para pressionar os comandos que passamos para ele.

  • A atividade do Unity que envolve o aplicativo será ativada antes do próprio Unity.

  • No caso de comandos enviados muito cedo, eles são armazenados em uma fila de callback.

    fun onStartMatchClickedInternal() {
       if (isServiceBound) {
           AIDLBridgeService.startMatch("match_id")
       } else {
           delayedCallbacksQueue.add(action)
       }
    }
  • Depois que o Unity conclui a inicialização e o carregamento de inicialização, e estamos em um estado que consideramos seguro, comunicamos do C# que o Unity está pronto.

    // After bootload and initialization
    
    using AndroidJavaClass nativeBridge = new("com.company.UnityActivity");
    using AndroidJavaObject instance = nativeBridge.GetStatic<AndroidJavaObject>("instance");
    instance.Call("onUnityReady");
  • Depois, fazemos um loop na fila e encaminhamos todos os comandos armazenados.

    @Suppress("unused") //  Unity implicit call
    fun onUnityReady() {
       bridge.activityBridgeServiceInternal?.onUnityReady()
    }
    
    delayedCallbacksQueue.forEach { it(activityBridgeService) }
  • Este diagrama ilustra melhor como funciona nossa comunicação entre processos.

  • Como você pode ver, usando a ponte entre as atividades, podemos processar os callbacks que mencionamos.

  • Configuramos StateFlows que são atualizados pelo Unity para monitorar valores como o status de autenticação ou o carimbo de data/hora do estado atual da partida.

  • Além disso, também gerenciamos a criptografia de credenciais e encaminhamos todos os erros do Unity para que o NBA possa depurar com mais facilidade e decidir como lidar com eles no nível do aplicativo.

  • Isso é tudo que posso dizer.

  • Agora, vou passar a palavra de volta para o Josh, que vai falar mais sobre como eles integram o pacote Court View Unity ao app da NBA.

  • Usamos essas dependências do Gradle para integrar o Unity como uma biblioteca ao nosso app do Jetpack Compose: o XR Runtime para acessar a sessão 3D, o Jetpack SceneCore para hospedar nossa atividade do Unity e alternar ambientes espaciais e o Jetpack Compose para XR para acessar painéis espaciais no nosso código do 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")
    }
  • Em seguida, vamos ver como renderizar a visualização da quadra no 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 iniciar o Court View, vamos colocar você no Espaço Ampliado.

  • Isso é necessário para mostrar o painel espacial que contém o Unity.

  • Em seguida, enviamos informações do jogo pela AIDL para iniciar a Court View de um jogo específico.

  • Em seguida, o Unity é iniciado.

  • Nosso arquivo do Android envia isso como uma intent para uma atividade do Unity, e nós o encapsulamos em um SpatialActivityPanelController.

  • Em seguida, basta transmitir esse controlador para um SpatialActivityPanel.

  • Esse painel é o que contém a visualização da quadra e é compartilhado no subespaço com o painel espacial principal.

  • Isso permite que controlemos os dois ao mesmo tempo sem problemas.

  • Pronto.

  • Vou enviar de volta para o Luke.

Conclusão e recursos

  • Muito obrigado.

  • Como você pode ver, a NBA conseguiu acelerar o Court View dividindo o desenvolvimento do Unity e do Jetpack Compose.

  • Para saber mais sobre como começar a usar contêineres espaciais e como usá-los nos seus apps Android XR, consulte a documentação para desenvolvedores em developer.android.com/develop/xr.

  • Agradecemos sua participação hoje e estamos ansiosos para ver o que você vai criar.

Código

Snippets de código

Snippet de script do Kotlin aos 09:30 em "Comunicação entre Kotlin e Godot"
androidResources {
     ignoreAssetsPattern = "!.svn:!.git:!.gitignore:!.ds_store:!*.scc:<dir>_*:!CVS:!*~:!android:!export_presets.cfg"
 }
Snippet de script do Kotlin aos 10:27 em "Comunicação entre Kotlin e 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 do Kotlin aos 10:48 em "Como renderizar o Godot no 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 do Kotlin aos 10:55 em "Como renderizar o Godot no Jetpack Compose"
Subspace {
        SpatialPanel(
            modifier = SubspaceModifier
                .width(960.dp)
                .height(540.dp)
                .offset(z = (-200).dp)
                .movable()
,
    ) {
        GodotView(
            godotHost = godotHost,
            modifier = Modifier.fillMaxSize(),
        )
    }
}
Snippet do Kotlin aos 11:05 em "Como renderizar o Godot no 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 do Kotlin às 11:29 em "Como renderizar o Godot no Jetpack Compose"
class MainActivity : ComponentActivity(), GodotHost {
    override fun getHostPlugins(engine: Godot): Set<GodotPlugin> =
        setOf(SoccerGodotPlugin(engine))
}
Snippet do Kotlin aos 11:52 em "Como renderizar o Godot no 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),
    )

}
Trecho de texto em 12:01 em "Como renderizar o Godot no 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 do Kotlin aos 12:10 em "Como renderizar o Godot no Jetpack Compose"
class SoccerGodotPlugin(godot: Godot) : GodotPlugin(godot) {

    fun sendCommandToGodot(command: String) {
        emitSignal("match_command", command)
    }
}
Trecho de texto aos 12:20 em "Como renderizar o Godot no Jetpack Compose"
func _on_goal_scored(minute: int, home: int, away: int) -> void:

    if soccer_plugin:
        soccer_plugin.onMatchEvent("GOAL", minute, home, away)
Snippet do Kotlin aos 12:30 em "Como renderizar o Godot no 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 do Kotlin aos 14:35 em "Como renderizar o Godot no 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 do Kotlin aos 14:57 em "Como renderizar o Godot no 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
       )
   }
Snippet XML em 15:22 em "Como renderizar o Godot no 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 do script do Kotlin aos 20:41 em "Como integrar o Unity como uma 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")
}
}
Snippet do Kotlin em 22:11 em "Como integrar o Unity como uma biblioteca"
// 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 em C# aos 22:36 em "Como integrar o Unity como uma biblioteca"
public class NativeInterfaceListener : AndroidJavaProxy {
[UsedImplicitly]
public void startMatch(string matchID) {
   UnityThreadHelper.InvokeOnMain(() => _internalAPI.StartMatch(matchID));
}
}
Snippet do Kotlin aos 22:54 em "Como integrar o Unity como uma biblioteca"
fun onStartMatchClickedInternal() {
   if (isServiceBound) {
       AIDLBridgeService.startMatch("match_id")
   } else {
       delayedCallbacksQueue.add(action)
   }
}
Snippet em C# aos 22:57 em "Como integrar o Unity como uma biblioteca"
// After bootload and initialization

using AndroidJavaClass nativeBridge = new("com.company.UnityActivity");
using AndroidJavaObject instance = nativeBridge.GetStatic<AndroidJavaObject>("instance");
instance.Call("onUnityReady");
Snippet do Kotlin aos 23:06 em "Como integrar o Unity como uma biblioteca"
@Suppress("unused") //  Unity implicit call
fun onUnityReady() {
   bridge.activityBridgeServiceInternal?.onUnityReady()
}

delayedCallbacksQueue.forEach { it(activityBridgeService) }
Snippet de script do Kotlin aos 23:50 em "Como integrar o Unity como uma 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")
}
Snippet do Kotlin aos 24:10 em "Como integrar o Unity como uma biblioteca"
// 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)
        }
    }
}