Android Staff Engineer
Hier erfahren Sie, wie Sie 3D-Game-Engines (Godot und Unity) mit Jetpack Compose in eine einzige Codebasis für Mobilgeräte und Android XR einbinden. Wir sehen uns an, wie 3D-Szenen in räumliche Container eingebettet werden und wie die Kommunikation zwischen Kotlin und der Engine eingerichtet wird. Außerdem erhalten Sie einen exklusiven Einblick, wie die NBA ihr Live-Basketball-Erlebnis auf Android XR entwickelt hat.
GodotPlugin-Unterklasse übernimmt dann die bidirektionale Kommunikation mit Kotlin.Transkript
Hallo zusammen.
Mein Name ist Luke Hopkins.
Ich bin Developer Relations Engineer im Android XR-Team.
In diesem Vortrag geht es darum, wie Sie Game-Engines wie Godot und Unity in Ihr Android-Projekt einbinden können.
Später werden Josh und Rodrigo von der NBA und Beyond Sports dazustoßen und darüber sprechen, wie sie Unity als Bibliothek in ihre NBA-Anwendung implementiert haben.
In dieser Sitzung werde ich darauf eingehen, wie Sie Godot in Ihre Jetpack Compose-Anwendung einbinden können. Anschließend werden Josh und Rodrigo ihre NBA-Anwendung vorstellen.
Android XR ist eine Erweiterung des Android-Ökosystems, die eine Vielzahl von Formfaktoren unterstützt und die vertraute Android-Umgebung in eine neue Dimension bringt.
Wir haben es intuitiv gestaltet, Ihre vorhandenen XR- und Android-Apps in diesen neuen Bereich zu bringen, und wir nutzen vorhandene Android-Technologien, mit denen Sie bereits heute entwickeln.
Android XR ist eine einheitliche Plattform, die für eine Vielzahl von Geräten entwickelt wurde.
Android XR-Geräte ermöglichen verschiedene Arten von Erlebnissen.
Es gibt erweiterte Funktionen für Audio- und Display-Brillen sowie immersive Funktionen für XR‑Headsets und kabelgebundene XR‑Datenbrillen.
Heute geht es um die Entwicklung immersiver Funktionen für kabelgebundene XR‑Datenbrillen und XR‑Headsets.
Immersive Erlebnisse können über unser Jetpack XR SDK oder mit OpenXR mit einer Echtzeit-Engine erstellt werden.
Wir unterstützen Unity, Godot und Unreal, damit Sie immersive Erlebnisse für Android erstellen können, unabhängig davon, welches Tool Sie verwenden.
In diesem Vortrag erkläre ich, wie ihr die SceneCore API von Jetpack zusammen mit Godot und einer neuen OpenXR-Erweiterung nutzen könnt, um das zu ermöglichen.
Hier sehen Sie Godot und die räumlichen Bereiche von Jetpack nebeneinander.
Godot wird als räumlicher Inhalt gerendert und bietet ein 3D-Volumen neben der 2D-Compose-UI in einem räumlichen Container.
So können Entwickler Physiksimulationen und benutzerdefinierte Shader mit der Echtzeit-Engine ihrer Wahl ausführen und gleichzeitig native Android-Entwicklungsmethoden auf den Rest der Anwendung anwenden.
Mit Android XR und XREAL AURA könnt auch ihr solche Erlebnisse schaffen.
In diesem Vortrag zeigen wir euch, wie ihr diese Funktion mit Godot und Android Studio erstellt. Außerdem erfahrt ihr von unseren Partnern bei der NBA und Beyond Sports, wie sie sie mit Unity als Bibliothek erstellt haben.
In der Vergangenheit bedeutete das Erstellen einer XR-Anwendung mit OpenXR, dass jeder einzelne Pixel gerendert werden musste.
Wenn ein Entwickler also eine zweite Aktivität anzeigen muss, kann er nicht beide gleichzeitig anzeigen.
Um dieses Problem zu lösen, hat Khronos, die OpenXR-Arbeitsgruppe, in Zusammenarbeit mit mehreren Branchenführern die Erweiterung OpenXR Spatial Containers veröffentlicht.
Stellen Sie sich räumliche Container als OpenXR in einer Box vor.
Anstatt dass die Engine die gesamte Skybox besitzt, wird ihr ein vom System verwaltetes 3D-Volumen zugewiesen, das neben den XR-Raumpanels von Jetpack, anderen räumlichen Containern und Standard-Android-Aktivitäten existieren kann.
Angenommen, Sie möchten Android XR nutzen, um zwei räumliche Bereiche gleichzeitig anzuzeigen, während ein immersives Spiel läuft.
Alles wird in der Engine gerendert. So können der Godot-Editor und ein räumlicher Container gleichzeitig ausgeführt werden.
Ab Spatial API-Level 4 hat Google Spatial Container in Android XR implementiert.
Spatial Containers unterstützen zwei wichtige Anzeigemodi.
Erstens: der begrenzte Modus.
Dadurch wird ein definiertes 3D-Volumen mit mehreren integrierten Ausweichmöglichkeiten bereitgestellt.
Nutzer können den räumlichen Container drehen, positionieren, minimieren und die Stereotiefenprojektion nutzen, um einen magischen Fenster- oder 3D-Pop-out-Effekt zu erzielen, während die Ränder des Containers abgeschnitten werden.
Der zweite ist der unbegrenzte Modus.
Wenn der Nutzer sich für die vollständige Immersion entscheidet, kann Ihre App einen nahtlosen Übergang vom begrenzten Modus in den Full Space-Modus anfordern. Sie können dann auch wieder in den begrenzten Modus zurückkehren.
Mit der Einführung von Spatial Containers bietet Android XR neue Möglichkeiten für innovative und umfassende Ideen.
Angenommen, Sie möchten Ihre Android-UI in Ihr Spiel einbinden oder einen benutzerdefinierten Shader erstellen, um 3D-Inhalte in Ihrer Anwendung anzuzeigen.
Vor der Einführung von räumlichen Containern war es nicht möglich, Compose und eine Game-Engine gleichzeitig auszuführen.
Und bei der Anwendungsentwicklung in Echtzeit-Engines haben wir im Grunde auch in zwei verschiedenen Paradigmen gelebt.
Für native Android-Entwickler ist die Laufzeit der Anwendung auf Jetpack Compose ausgelegt.
Es ist für eine schnelle, reaktionsfähige Benutzeroberfläche, Akkueffizienz, dynamische Layouts, Systemzugänglichkeit und nahtlose Texteingabe konzipiert.
Sie befindet sich im Ruhemodus, wenn nichts passiert, und sie ist vorhersehbar.
Für Entwickler von Spiele-Engines ist das Echtzeit-Rendering der Bereich, in dem Tools wie Godot und Unity glänzen.
Sie warten nicht nur auf Nutzerinteraktionen.
Sie führen eine kontinuierliche Tick- und Render-Schleife aus.
Das können 60, 90 oder 100 Bilder pro Sekunde sein.
Sie führen physikalische Berechnungen, komplexe Shader, Skelettanimationen und Spatial Audio aus – und das alles mit einer unglaublichen Leistung.
Was aber, wenn Sie das Beste aus beiden Welten wollen?
Bisher mussten Entwickler einen dieser beiden Ansätze implementieren.
Option A besteht darin, die gesamte Anwendung in einer echten Engine wie Godot oder Unity neu zu schreiben.
Wenn Sie Ihre vorhandene Codebasis verwerfen, verlieren Sie einen Großteil Ihrer nativen Compose-UI, müssen grundlegende Text- und Navigationsfunktionen neu implementieren und Wrapper für Android APIs wie Text-to-Speech schreiben.
Option B besteht darin, in Ihrer systemeigenen App mit Vulkan- und OpenXR-APIs eine Low-Level-Pipeline für das Rendern von 3D-Grafiken von Grund auf neu zu erstellen.
Anstatt Produktfunktionen bereitzustellen, erstellen Sie im Grunde Ihre eigene proprietäre Engine.
Heute zeige ich Ihnen, wie Sie Godot und Unity als Bibliotheken optimal nutzen können.
Mit den neuen räumlichen Containern können Sie jetzt das Beste aus beiden Welten nutzen.
Dazu binden wir eine Echtzeit-Engine direkt in eine Bibliothek in Ihrer Jetpack-Anwendung ein.
Sie behalten die Struktur der Android-Produktions-App, die Geschäftslogik und das nahtlose Rendering von räumlichen 3D-Inhalten in Ihrer Anwendung bei.
Das bedeutet auch, dass Sie ein APK für alle Formfaktoren verwenden können.
Wenn Sie nicht nur 3D-Inhalte anzeigen möchten, können Sie die Bibliothek nicht in die Aktivität oder das Fragment laden und Ihre Codebasis übersichtlich halten. So können Sie viele Geräte unterstützen.
Die Philosophie ist einfach: Die Anwendung wird entkoppelt und die Echtzeit-Engine wird zu einer Bibliothek.
Anstatt sich zwischen einer Android-App oder einer Engine zu entscheiden, behandeln Sie die Engine als eingebettete Softwarebibliothek, auch als AAR-Abhängigkeit bezeichnet.
Sie binden sie wie jede andere Bibliothek direkt in Ihren Gradle-Build ein.
Der native Android-Code bleibt das absolute Gehirn der Anwendung, die die 3D-Komponenten hostet.
Jetpack Compose XR übernimmt das, was es am besten kann: das Rendern nativer, barrierefreier und flüssiger Benutzeroberflächen und das Verwalten des App-Status.
Wenn ein 3D-Element benötigt wird, hostet die App eine eingebettete Rendering-Ansicht, die von der Game-Engine selbst gerendert wird.
Heute sehen wir uns genauer an, wie das bei Godot funktioniert.
Ich beginne mit den Mechanismen zum Einbetten und Einrichten der bidirektionalen Kommunikation zwischen Kotlin und GDScript.
Anschließend sehen wir uns die neue OpenXR-Erweiterung „Spatial Container“ an, die eine neue Generation von Multitasking und räumlichem Layout ermöglicht.
Sehen wir uns noch einmal die Demo von vorhin an.
Ich habe es mit Godot und Android Studio erstellt.
In diesem Clip sehen Sie, wie Jetpack Compose für XR native 2D-UI-Bereiche für die Anzeigetafel, Benachrichtigungen und Highlights rendert.
Außerdem rendert Godot eine Simulation eines Fußballspiels.
Hier siehst du eine Übersicht darüber, wie wir von Kotlin zu GDScript kommunizieren.
Dafür bietet Godot eine Plug-in-Architektur.
Sie schreiben eine einfache Kotlin-Klasse, die eine GodotPlugin erweitert.
Diese Klasse dient als Schnittstelle zwischen der JVM und der C++-Laufzeit von Godot.
Daher sind Interaktionen zwischen Ihrer App und Godot sehr einfach.
Die Architektur von Godot unterstützt die bidirektionale Kommunikation zwischen Godot und Kotlin.
Wenn in Godot ein Spielereignis auftritt, z. B. ein Tor oder ein Foul, kann Godot Kotlin-Funktionen direkt aufrufen und die Compose-UI in Echtzeit aktualisieren.
Wenn der Nutzer dann mit dem Mediaplayer interagiert, z. B. um die Wiedergabe zu pausieren oder andere Highlights auszuwählen, sendet Kotlin ein Signal zurück an Godot und die entsprechenden Aktionen der Game-Engine werden ausgeführt.
Sobald das Godot-Plug-in integriert ist, fallen Leistung und Verwaltung einfach in die Domain-Linien.
Wenden Sie die Best Practices für Android-Apps und Godot-Projekte an.
Sehen wir uns nun die Architektur an.
Sehen wir uns an, wie wir das in Godot in eine Android-App einbinden.
Die Godot-Engine-Laufzeit wird über Maven als AAR bereitgestellt.
Die Einbindung in Ihren Build erfolgt nach den standardmäßigen Android-Verfahren.
Es gibt einen wichtigen Schritt, den Sie sofort beachten sollten, und das ist die sogenannte Asset-Falle.
androidResources {
ignoreAssetsPattern = "!.svn:!.git:!.gitignore:!.ds_store:!*.scc:<dir>_*:!CVS:!*~:!android:!export_presets.cfg"
}In einem Godot-Projekt befinden sich Ihre Szenen und Projektkonfigurationsdateien am selben Ort.
Wenn Sie Godot einbetten, wird das Godot-Projekt direkt in Ihrem Asset-Ordner hinzugefügt.
Das bedeutet, dass alle importierten 3D-Meshes, komprimierten Texturen und kompilierten Texturen als Laufzeitartefakt in einer versteckten Datei namens .godot gespeichert werden.
Standardmäßig entfernt Android diesen Ordner beim Erstellen des Builds, sodass alle Verzeichnisse mit einem Punkt ignoriert werden.
Wenn Sie Ihre App also normal synchronisieren und erstellen, wird das APK erstellt, aber es stürzt ab und wird geladen, weil diese Elemente nicht in das APK kompiliert wurden.
Um das Problem zu beheben, können Sie die AAPT-Optionen in Ihrer „build.gradle“-Datei explizit überschreiben und die führenden Punkte von Godot zulassen.
Anschließend können Sie Ihre Godot-Szene einmal im Godot-Editor ausführen und von dort aus alles Weitere in Android Studio erstellen.
Zur Unterstützung von räumlichen Containern und der neuesten OpenXR empfehle ich die Verwendung von Godot 4.8 und höher, Godot-Anbietererweiterungen 5.2 und dem Khronos-OpenXR-Loader 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")Wie werden die Assets nach dem Packen von Godot und Compose gerendert?
Die Godot-Bibliothek bietet eine Factory-Methode namens 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 }
)
}Dadurch wird ein SurfaceView zurückgegeben, auf dem Godot nur rendert.
Sie verpacken dies also in eine zusammensetzbare Funktion und Godot kann jetzt in der Größe angepasst, mit Abständen versehen und an einer beliebigen Stelle in einem Entitätsbereich platziert werden.
Subspace {
SpatialPanel(
modifier = SubspaceModifier
.width(960.dp)
.height(540.dp)
.offset(z = (-200).dp)
.movable()
,
) {
GodotView(
godotHost = godotHost,
modifier = Modifier.fillMaxSize(),
)
}
}Die Engine selbst kann den Lebenszyklus nicht verwalten.
@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)
}
}
}Ihre Aktivität muss also die Schnittstelle GodotHost implementieren.
Dadurch wird sichergestellt, dass alle Lebenszyklen, die in Ihrer Aktivität auftreten, z. B. beim Pausieren oder Fortsetzen, an die Godot-Engine gesendet werden und diese nun über den Lebenszyklus informiert ist.
Als Nächstes müssen wir unsere systemeigene App und unsere Engine hinzufügen, damit sie nahtlos miteinander kommunizieren können.
Dazu können wir die GodotHost-Schnittstelle verwenden.
class MainActivity : ComponentActivity(), GodotHost {
override fun getHostPlugins(engine: Godot): Set<GodotPlugin> =
setOf(SoccerGodotPlugin(engine))
}Wir können das Godot-Plug-in definieren und dafür sorgen, dass das Plug-in beim Start von Godot von der Compose-Seite registriert wird.
Das Plug-in ist jetzt registriert. Als Nächstes richten wir Android so ein, dass es mit Godot kommuniziert, indem es ein Signal sendet.
In unserem Kotlin-Code erstellen wir eine Instanz von GodotPlugin.
Wir lassen das Plug-in von unserer Aktivität bei der Godot-Bibliothek registrieren.
In Kotlin erstellen wir dann die GodotPlugin, registrieren ihren Namen und teilen Godot alle Signale mit, die wir in diesem Paket verwenden möchten.
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),
)
}Als Nächstes haben wir ein Godot-Script, mit dem Sie die Instanz dieses Plug-ins abrufen können.
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)Wir können sehen, was registriert ist, und dann hier die Funktionen mit den Signalen verbinden, die wir registriert haben.
Wenn Sie dann etwas wie emitSignal aufrufen, können Sie es einfach aus Ihrem Kotlin-Code übergeben und es wird direkt an Godot weitergeleitet.
class SoccerGodotPlugin(godot: Godot) : GodotPlugin(godot) {
fun sendCommandToGodot(command: String) {
emitSignal("match_command", command)
}
}Damit Godot mit Kotlin kommunizieren kann, wenn beispielsweise ein Tor erzielt wird, können wir eine Kotlin-Funktion direkt über das Godot-Script aufrufen, indem wir einfach die Plug-in-Instanz verwenden.
func _on_goal_scored(minute: int, home: int, away: int) -> void:
if soccer_plugin:
soccer_plugin.onMatchEvent("GOAL", minute, home, away)Zuerst annotieren Sie die Kotlin-Funktion mit 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)
}
}Die Engine übernimmt das JNI-Marshalling im Hintergrund.
Anschließend können wir die Funktion direkt aus Godot Script aufrufen.
Ein wichtiger Hinweis: Bei UsedByGodot ist eine genaue Stringübereinstimmung erforderlich.
Wenn Ihr Godot-Script also onMatchEvent aufruft, muss der Kotlin-Name genau gleich sein. Es gibt keine automatische Camel-Case- oder Snake-Case-Konvertierung.
Sie müssen also nur dafür sorgen, dass beide Methoden genau gleich sind.
Nachdem das Plug-in verbunden ist, kommen wir zum wichtigsten Teil dieses Vortrags, dem mentalen Modell.
Hier arbeiten Spiele-Engines und Jetpack Compose mit zwei grundlegend gegensätzlichen Architekturen.
Eine Game-Engine arbeitet mit einem kontinuierlichen Frame-Tick.
Unabhängig davon, ob du etwas geändert hast oder nicht, wird der Tick-Prozess der Spiel-Engine 60-, 90- oder 100-mal pro Sekunde ausgeführt, um ein umfassendes und reibungsloses Rendering in Echtzeit zu ermöglichen.
Engines wie Godot nutzen die GPU- und CPU-Zyklen effektiv.
Jetpack Compose hingegen verarbeitet Dinge reaktiv und ist zustandsgesteuert.
Und wenn sich eine App nicht ändert, wird Compose einfach fortgesetzt und verbraucht 0 CPU.
Wenn Sie diese verbinden möchten, um zwei Laufzeiten ohne klare Strategie auszuführen, kollidieren diese Philosophien tendenziell.
Sehen wir uns also an, wie wir dieses Problem umgehen können, indem wir das tun, was ich als „Tick-Leak“ bezeichne.
Hier haben wir ein 3D-Erlebnis, zum Beispiel den Timer auf der Anzeigetafel.
In Ihrem Engine-Skript gibt es eine Timer-Variable „ti-“, die sich ständig ändert.
Wenn Sie diese Variable blind an Compose weiterleiten, sagen wir 90 Mal pro Sekunde, wird diese Rekompositionsschleife 90 Mal pro Sekunde aufgerufen.
Das bedeutet, dass Compose Ihre Composables neu bewertet, Layouts neu misst und UI-Bäume 90-mal pro Sekunde neu zeichnet.
Und ihr werdet Frame-Drops und UI-Verzögerungen sehen und ziemlich schnell die thermische Drosselung erreichen.
Die goldene Regel lautet also: Lassen Sie die kontinuierliche Engine-Schleife niemals die reaktive UI-Schleife diktieren.
In diesem Beispiel wird der Timer der Anzeigetafel nicht kontinuierlich von Godot aktualisiert. Stattdessen werden diese Daten einmal pro Sekunde von Android abgerufen, wenn sie benötigt werden.
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, ...) }
}Denken Sie immer an die Thread-Grenze.
Eine Godot-Game-Engine-Schleife wird kontinuierlich in einem eigenen Rendering-Thread ausgeführt.
Die Android-Benutzeroberflächen müssen im Android-Hauptthread ausgeführt werden.
Achten Sie also immer darauf, dass Ihr Kotlin-Plug-in Ereignisse mit Dispatchers.Main oder Safe-State-Primitiven sendet, bevor Sie die Ansichtsmodellstatus aktualisieren.
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
)
}Wenn Sie hochfrequente Aufgaben in der Engine und diskrete Semantik in Compose belassen, erhalten Sie flüssige 3D-Darstellungen, ohne dass die Akkulaufzeit, die Barrierefreiheit im Internet oder die Leistung Ihrer systemeigenen App beeinträchtigt werden.
Sehen wir uns nun an, wie Sie Ihrem Godot-Projekt räumliche Container hinzufügen können.
Zuerst richten wir die App so ein, dass sie im Full Space ausgeführt wird, aktivieren räumliche Container mit ihren Begrenzungshinweisen und legen dann die XR-Ladebibliothek in der Android-Manifestdatei fest.
<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 hat eigene XR-Projekteinstellungen, in denen ich OpenXR, XR-Shader und die räumlichen Container direkt über den Editor aktivieren kann.
Das ist im Grunde alles, was Sie tun müssen, um Spatial Containers in Godot zu aktivieren.
Nachdem Sie Godot mit Android XR, räumlichen Containern und all diesen Funktionen eingerichtet haben, empfehlen wir Ihnen, sich auf der Entwicklerwebsite über die Best Practices für die Verwendung von räumlichen Containern, räumlichen Bereichen und JXR zu informieren.
Wir haben uns gerade ein Beispiel für die Verwendung von räumlichen Containern in der Godot Engine und Android XR angesehen. Sicherlich möchten viele von Ihnen von einem Entwickler hören, der aktiv an räumlichen Containern für seine Android XR-Anwendung arbeitet.
Als Nächstes möchte ich Josh und Rodrigo von der NBA einladen, einem der ersten Partner, die Spatial Containers in ihrer Anwendung verwenden.
Danke, Luke.
Hallo zusammen.
Ich heiße Josh und bin Android-Entwickler bei der NBA.
Ich bin heute hier mit meinem Kollegen Rodrigo Sanchez, einem Unity-Softwareentwickler bei Beyond Sports.
Heute möchten wir Ihnen zeigen, wie wir Unity as a Library verwendet haben, um Court View für die NBA App zu entwickeln.
Wir bei der NBA sind immer auf der Suche nach neuen Erlebnissen für unsere Fans.
Deshalb freuen wir uns, gemeinsam mit Google und Beyond Sports Court View vorstellen zu können.
Court View bietet 3D-Nachbildungen von NBA-Spielen, sodass League Pass-Abonnenten die Spiele aus neuen Perspektiven ansehen können, die bei normalen Übertragungen nicht möglich sind.
Skaliere und drehe das Spielfeld und platziere es an einer beliebigen Stelle in deinem Raum.
Du kannst dich um das Spiel herum bewegen und es aus einzigartigen Blickwinkeln betrachten.
Währenddessen kannst du die NBA App wie gewohnt verwenden, um dir Statistiken anzusehen oder die Liveübertragung zu verfolgen.
Wie wird das alles eingerichtet?
Court View wird in Unity gerendert und von unserer Android-App über Unity als Bibliothek verwendet.
Anschließend erstellen wir ein einzelnes Android-Archiv, das auf Maven Central gehostet wird und von der NBA-App als einzelne Abhängigkeit abgerufen werden kann.
So konnten wir die Arbeit zwischen der Android- und der Unity-Seite aufteilen.
So kann sich jeder von uns auf das konzentrieren, was er am besten kann, und wir können parallel arbeiten, um die Entwicklung insgesamt zu beschleunigen.
Während unser Team beispielsweise an der Full Space-Umgebung in Compose gearbeitet hat, konnte sich das Unity-Team auf die Skalierung und das Layout des 3D-Spielfelds konzentrieren.
Jetzt übergebe ich an Rodrigo, der mehr über das Unity-Projekt und die Zusammenarbeit mit Unity as a Library erzählen wird.
Vielen Dank, Josh.
Wir bei Beyond Sports arbeiten seit einigen Jahren mit der NBA zusammen, um 3D-Visualisierungen mit echten Daten aus Spielen darzustellen.
Im Rahmen dieses Projekts haben wir unsere 3D-Simulation so angepasst, dass sie mit Android XR kompatibel ist.
Unsere Simulation basiert auf der Unity-Engine mit C#.
Wir exportieren die Simulation mit Unity as a Library nach Android und erstellen daneben eine Kommunikationsebene mit Kotlin.
Bevor diese beiden Module als einzelne Maven-Abhängigkeit gebündelt werden, die die NBA in ihre Anwendung einbinden kann.
Auf dem Bildschirm sehen Sie ein kurzes Video derselben Simulation, die Josh zuvor gezeigt hat, nur dass sie im Editor ausgeführt wird.
Dazu verwenden wir das Android XR Extensions-Paket für Unity.
Ich zeige Ihnen, wie das geht.
Bevor Sie also in Unity entwickeln, sollten Sie die richtigen Einstellungen und Plug-ins auswählen.
Laden Sie das XR-Plug-in-Verwaltungspaket von Unity und das Android XR-Erweiterungspaket für Unity von Google herunter.
Konfiguriere sie in den Einstellungen für die XR-Plug-in-Verwaltung, um den Android XR-Erweiterungsanbieter OpenXR zu verwenden.
Für unseren Anwendungsfall wollten wir Spatial Containers verwenden. Deshalb haben wir diese Funktion aktiviert und immersives XR in den Einstellungen für die Sitzungsverwaltung deaktiviert.
Wir haben kein XR-Eingabepaket verwendet, da alle Eingaben nativ außerhalb von Unity verarbeitet werden.
Als Nächstes müssen Sie in den Player-Einstellungen festlegen, dass Ihr Projekt mindestens das API-Level 24 oder höher verwendet. Außerdem muss der Einstiegspunkt der Anwendung auf die neuere GameActivity festgelegt werden.
Achten Sie darauf, dass „Aktivität“ nicht ausgewählt ist.
Wir möchten nur für ARM64 entwickeln, also wählen wir diese Zielarchitektur aus. Im Abschnitt „Resolution“ (Auflösung) aktivieren wir „Run in Background“ (Im Hintergrund ausführen), damit Sie andere Apps verwenden können, während die 3D-Simulation im Hintergrund weiterläuft.
Wir aktivieren auch die Funktion „Resizeable Activity“, da sie zum Rendern von Dialogen wie Systemberechtigungsanfragen erforderlich ist.
Außerdem wird empfohlen, Android XR-Apps mit der Universal Render Pipeline und der Vulkan Graphics API zu rendern.
In Ihrem URP-Asset sollten HDR und die Nachbearbeitung deaktiviert sein.
Wenn Sie alle diese Schritte ausgeführt haben, können Sie einen Build erstellen.
Wechseln Sie zum Build-Fenster. Wenn Sie Unity 6.1 oder höher verwenden, können Sie das Android XR-Build-Profil auswählen. Damit können Sie die Einstellungen trennen, wenn Sie auch für Android Mobile entwickeln.
Aktivieren Sie vor dem Erstellen das Exportprojekt.
Nachdem Unity den Build erstellt hat, haben Sie ein funktionierendes Android-Projekt, das in Android Studio zusammengestellt und wie jedes andere Unity-APK auf einem Gerät ausgeführt werden kann.
In Android Studio müssen Sie den exportierten Build in eine Bibliothek umwandeln, die von einer anderen Anwendung verwendet werden kann.
// 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")
}
}In unserem Projekt haben wir vier verschiedene Module: das Haupt-APK-Modul zum Testen als Mock-up-Platzhalter für die Wrapping-Anwendung der NBA, das Unity Library-Modul mit unserem exportierten Build, ein nativeCommunications-Modul mit unserer Kommunikationsschicht, die wir zwischen den Aktivitäten erstellt haben, und ein packageExporter-Modul, mit dem wir unsere AARs in einem einzigen Paket zusammenfassen, um sie an Maven zu senden.
Mehrere AARs können mit Android Fused Library oder älteren Community-Methoden wie Fat AAR zusammengeführt werden.
Sie können die Struktur und Kommunikation einfach halten, indem Sie Unity und die Hauptaktivität im selben Prozess ausführen.
Wir haben uns dagegen entschieden und die Unity-Aktivität in einen eigenen Prozess ausgelagert. So wird die NBA App nicht beeinträchtigt, wenn der Unity-Teil abstürzt, und kann Unity ordnungsgemäß schließen, ohne selbst abzustürzen.
Diese Entscheidung zwingt uns, ein robusteres System für die Interprozesskommunikation einzuführen.
Da sich die Aktivitäten in verschiedenen Prozessen befinden, wird kein Speicher zwischen ihnen geteilt. Viele gängige Android-IPC-Methoden funktionieren nicht oder erfüllen die Anforderungen nicht.
Wir verwenden die Android Interface-Definitionssprache (AIDL), um zwischen der Hauptaktivität der NBA und unserer eingebetteten Unity-Aktivität zu kommunizieren.
Die Kommunikation zwischen Android und Unity basiert ebenfalls auf Schnittstellen. Dabei wird die integrierte AndroidJavaProxy in Unity verwendet, um eine Kotlin-Schnittstelle für eine C#-Schnittstellenimplementierung zu registrieren.
Unser Kommunikationsmodul konzentriert sich hauptsächlich auf die sogenannte Aktivitätsbrücke.
// 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) }
}
}Beide Aktivitäten haben Zugriff auf die Aktivitätsbrücke und die darin enthaltenen Hilfsmethoden.
Die Brücke enthält einen Verweis auf einen Dienst, der die von uns definierten AIDL-Schnittstellen implementiert. Außerdem bietet sie eine einfache Möglichkeit für die Aktivitäten, sich an den Prozess, Entschuldigung, an den Dienst zu binden, und verwaltet intern dessen Lebenszyklus.
Eine Simulation kann mit einem Callback gestartet werden, bei dem die Match-ID als Parameter übergeben wird.
Anschließend wird sie über AIDL an die Unity-App weitergeleitet, wo C# einen Befehl erhält und mit der Konfiguration der Szene beginnt.
public class NativeInterfaceListener : AndroidJavaProxy {
[UsedImplicitly]
public void startMatch(string matchID) {
UnityThreadHelper.InvokeOnMain(() => _internalAPI.StartMatch(matchID));
}
}Wir müssen warten, bis Unity aktiv und bereit ist, die Befehle auszuführen, die wir an Unity übergeben.
Die Unity-Aktivität, die die Anwendung umschließt, ist aktiv, bevor Unity selbst aktiv ist.
Befehle, die zu früh gesendet werden, werden in einer Callback-Warteschlange gespeichert.
fun onStartMatchClickedInternal() {
if (isServiceBound) {
AIDLBridgeService.startMatch("match_id")
} else {
delayedCallbacksQueue.add(action)
}
}Nachdem Unity das Bootloading und die Initialisierung abgeschlossen hat und wir uns in einem Zustand befinden, den wir als sicher betrachten, kommunizieren wir von C# aus, dass Unity bereit ist.
// After bootload and initialization
using AndroidJavaClass nativeBridge = new("com.company.UnityActivity");
using AndroidJavaObject instance = nativeBridge.GetStatic<AndroidJavaObject>("instance");
instance.Call("onUnityReady");Anschließend durchlaufen wir die Warteschlange und leiten alle gespeicherten Befehle weiter.
@Suppress("unused") // Unity implicit call
fun onUnityReady() {
bridge.activityBridgeServiceInternal?.onUnityReady()
}
delayedCallbacksQueue.forEach { it(activityBridgeService) }Dieses Diagramm veranschaulicht die Interprozesskommunikation.
Wie Sie sehen, können wir mit der Brücke zwischen Aktivitäten die erwähnten Callbacks verarbeiten.
Wir haben StateFlows eingerichtet, die von Unity aktualisiert werden, um Werte wie den Authentifizierungsstatus oder den Zeitstempel des aktuellen Status des Spiels zu überwachen.
Außerdem verwalten wir die Verschlüsselung von Anmeldedaten und leiten alle Fehler von Unity weiter, damit die NBA sie leichter beheben und entscheiden kann, wie sie am besten auf Anwendungsebene behandelt werden.
Das war's von meiner Seite.
Jetzt übergebe ich wieder an Josh, der mehr darüber erzählen wird, wie das Court View Unity-Paket in die NBA-App integriert wird.
Wir verwenden diese Gradle-Abhängigkeiten, um Unity als Bibliothek in unsere Jetpack Compose-App einzubinden: XR Runtime für den Zugriff auf die 3D-Sitzung, Jetpack SceneCore zum Hosten unserer Unity-Aktivität und zum Wechseln zwischen räumlichen Umgebungen und Jetpack Compose for XR für den Zugriff auf räumliche Bereiche in unserem Compose-Code.
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")
}Als Nächstes sehen wir uns an, wie wir Court View in Jetpack Compose rendern.
// 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)
}
}
}Bevor wir die Court View starten, wechseln wir in den Full Space.
Dies ist erforderlich, um das räumliche Steuerfeld mit Unity anzuzeigen.
Als Nächstes senden wir Spielinformationen über AIDL, um Court View für ein bestimmtes Spiel zu starten.
Anschließend wird Unity gestartet.
Unser Android-Archiv sendet dies als Intent an eine Unity-Aktivität, die wir in einen SpatialActivityPanelController einbinden.
Anschließend übergeben wir diesen Controller an einen SpatialActivityPanel.
In diesem Bereich befindet sich die Court View. Er wird in unserem Unterbereich mit dem Hauptbereich geteilt.
So können wir beide problemlos gleichzeitig steuern.
Das wars!
Ich schicke es an Luke zurück.
Vielen Dank.
Wie Sie sehen, konnte die NBA die Entwicklung von Court View beschleunigen, indem sie die Unity- und Jetpack Compose-Entwicklung aufgeteilt hat.
Weitere Informationen zu den ersten Schritten mit räumlichen Containern und ihrer Verwendung in Ihren eigenen Android XR-Apps finden Sie in der Entwicklerdokumentation unter developer.android.com/develop/xr.
Vielen Dank, dass Sie heute dabei waren. Wir sind gespannt, was Sie entwickeln werden.
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)
}
}
}