Engenheiro de relações com desenvolvedores do Android XR
Engenheiro sênior de Android
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.
GodotPlugin processa a comunicação bidirecional com o Kotlin.Transcrição
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.
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.
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.
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")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.
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.
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.
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.
androidResources {
ignoreAssetsPattern = "!.svn:!.git:!.gitignore:!.ds_store:!*.scc:<dir>_*:!CVS:!*~:!android:!export_presets.cfg"
}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")@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 }
)
}Subspace {
SpatialPanel(
modifier = SubspaceModifier
.width(960.dp)
.height(540.dp)
.offset(z = (-200).dp)
.movable()
,
) {
GodotView(
godotHost = godotHost,
modifier = Modifier.fillMaxSize(),
)
}
}@Composable
fun GodotView(
godotHost: GodotHost,
modifier: Modifier = Modifier,
lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
) {
DisposableEffect(lifecycleOwner, godotHost) {
val observer = LifecycleEventObserver { _, event ->
val engine = godotHost.godot ?: return@LifecycleEventObserver
when (event) {
Lifecycle.Event.ON_START -> engine.onStart(godotHost)
….
Lifecycle.Event.ON_DESTROY -> engine.onDestroy(godotHost)
else -> {}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
}class MainActivity : ComponentActivity(), GodotHost {
override fun getHostPlugins(engine: Godot): Set<GodotPlugin> =
setOf(SoccerGodotPlugin(engine))
}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),
)
}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)class SoccerGodotPlugin(godot: Godot) : GodotPlugin(godot) {
fun sendCommandToGodot(command: String) {
emitSignal("match_command", command)
}
}func _on_goal_scored(minute: int, home: int, away: int) -> void:
if soccer_plugin:
soccer_plugin.onMatchEvent("GOAL", minute, home, away)@UsedByGodot
fun onMatchEvent(type: String, minute: Int, home: Int, away: Int, title: String) {
_uiState.update { state ->
state.copy(homeScore = home, awayScore = away, matchSeconds = minute)
}
}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, ...) }
}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
)
}<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="" />// 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")
}
}// 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) }
}
}public class NativeInterfaceListener : AndroidJavaProxy {
[UsedImplicitly]
public void startMatch(string matchID) {
UnityThreadHelper.InvokeOnMain(() => _internalAPI.StartMatch(matchID));
}
}fun onStartMatchClickedInternal() {
if (isServiceBound) {
AIDLBridgeService.startMatch("match_id")
} else {
delayedCallbacksQueue.add(action)
}
}// After bootload and initialization
using AndroidJavaClass nativeBridge = new("com.company.UnityActivity");
using AndroidJavaObject instance = nativeBridge.GetStatic<AndroidJavaObject>("instance");
instance.Call("onUnityReady");@Suppress("unused") // Unity implicit call
fun onUnityReady() {
bridge.activityBridgeServiceInternal?.onUnityReady()
}
delayedCallbacksQueue.forEach { it(activityBridgeService) }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")
}// 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)
}
}
}