Inżynier ds. relacji z deweloperami Androida XR
Inżynier ds. Androida
Dowiedz się, jak zintegrować silniki gier 3D (Godot i Unity) z Jetpack Compose w jednej bazie kodu na urządzenia mobilne i Androida XR. Omówimy osadzanie scen 3D w kontenerach przestrzennych i konfigurowanie komunikacji między Kotlinem a silnikiem. Przyjrzymy się też, jak NBA stworzyła transmisje na żywo z meczów koszykówki na Androida XR.
GodotPlugin obsługuje następnie komunikację dwukierunkową z Kotlinem.Transkrypcja
Cześć!
Nazywam się Luke Hopkins.
Jestem inżynierem ds. relacji z deweloperami w zespole Androida XR.
W dzisiejszej prezentacji omówimy, jak można umieszczać silniki gier, takie jak Godot i Unity, w projekcie aplikacji na Androida.
Później dołączą do mnie Josh i Rodrigo z NBA i Beyond Sports, którzy opowiedzą o tym, jak wdrażają Unity jako bibliotekę w aplikacji NBA.
Podczas tej sesji omówię, jak zintegrować Godot z aplikacją Jetpack Compose, a następnie Josh i Rodrigo przedstawią swoją aplikację NBA.
Android XR to rozszerzenie ekosystemu Androida, które obejmuje szeroką gamę formatów, aby nadać znane i lubiane funkcje nowy wymiar.
Zadbaliśmy o to, aby przenoszenie istniejących aplikacji XR i aplikacji na Androida do tej nowej przestrzeni było intuicyjne. Wykorzystujemy też istniejące technologie Androida, których już używasz.
Android XR to ujednolicona platforma zaprojektowana z myślą o szerokiej gamie urządzeń.
Urządzenia z Androidem XR obsługują różne rodzaje treści.
Mamy ulepszone funkcje audio i okulary z wyświetlaczem, a także immersyjne gogle XR i przewodowe okulary XR.
Dzisiaj omówimy tworzenie immersyjnych doświadczeń na przewodowe okulary XR i gogle XR.
Wciągające doświadczenia można tworzyć za pomocą pakietu Jetpack XR SDK lub OpenXR z silnikiem działającym w czasie rzeczywistym.
Obsługujemy Unity, Godot i Unreal, dzięki czemu możesz tworzyć immersyjne treści na Androida niezależnie od tego, jakiego narzędzia używasz.
W tym wystąpieniu wyjaśnię, jak można to osiągnąć, wykorzystując interfejs API SceneCore Jetpacka wraz z Godotem i nowym rozszerzeniem OpenXR.
Tutaj widzisz silnik Godot działający obok paneli przestrzennych Jetpacka.
Zwróć uwagę, że Godot jest renderowany jako treść przestrzenna, co zapewnia objętość 3D obok interfejsu Compose UI 2D w kontenerze przestrzennym.
Dzięki temu programiści aplikacji na Androida mogą uruchamiać symulacje fizyki i niestandardowe shadery przy użyciu wybranego silnika działającego w czasie rzeczywistym, a w pozostałej części aplikacji stosować natywne metody programowania na Androida.
Dzięki Androidowi XR i XREAL AURA Ty też możesz tworzyć takie rozwiązania.
W tej prezentacji pokażemy, jak stworzyć takie środowisko za pomocą Godot i Android Studio. Następnie nasi partnerzy z NBA i Beyond Sports opowiedzą, jak stworzyli je przy użyciu Unity jako biblioteki.
Wcześniej tworzenie środowiska XR za pomocą OpenXR oznaczało tworzenie środowiska, które renderuje każdy piksel.
Oznacza to, że jeśli deweloper musi wyświetlić drugą aktywność, nie może mieć obu jednocześnie.
Aby rozwiązać ten problem, Khronos, grupa robocza OpenXR, we współpracy z kilkoma liderami branży, wydała rozszerzenie OpenXR Spatial Containers.
Kontenery przestrzenne można porównać do OpenXR w pudełku.
Zamiast przejmować cały skybox, silnik otrzymuje zarządzany przez system obszar 3D, który może współistnieć z panelami przestrzennymi XR Jetpacka, innymi kontenerami przestrzennymi i standardowymi aktywnościami na Androidzie.
Załóżmy, że chcesz wykorzystać Androida XR, wyświetlając 2 panele przestrzenne jednocześnie podczas grania w grę immersyjną.
Wszystko renderujesz w silniku, co w tych przypadkach umożliwia jednoczesne działanie edytora Godot i kontenera przestrzennego.
Od poziomu 4 interfejsu Spatial API Google wdraża kontenery przestrzenne w Androidzie XR.
Kontenery przestrzenne obsługują 2 ważne tryby wyświetlania.
Pierwszy to tryb ograniczony.
Zapewnia to zdefiniowaną przestrzeń 3D z kilkoma wbudowanymi funkcjami unikania.
Użytkownik może chwycić kontener przestrzenny, obrócić go, ustawić, zminimalizować, a także korzystać z projekcji głębi stereo, co daje efekt magicznego okna lub wyskakującego okienka 3D, a także umożliwia przycinanie krawędzi kontenera.
Drugi to tryb nieograniczony.
Jeśli użytkownik zdecyduje, że chce pełnej immersji, aplikacja może poprosić o płynne przejście z trybu ograniczonego do trybu pełnej przestrzeni, a następnie możesz też wrócić do trybu ograniczonego.
Dzięki dodaniu kontenerów przestrzennych Android XR otwiera drzwi dla nowych i rozbudowanych pomysłów.
Załóżmy, że chcesz przenieść interfejs Androida do gry lub utworzyć niestandardowy cieniujący do wyświetlania treści 3D w aplikacji.
Przed wprowadzeniem kontenerów przestrzennych nie było możliwe jednoczesne uruchomienie Compose i silnika gry.
Jeśli chodzi o tworzenie aplikacji w silnikach działających w czasie rzeczywistym, również funkcjonowaliśmy w 2 różnych paradygmatach.
W przypadku programistów aplikacji natywnych na Androida środowisko wykonawcze aplikacji jest oparte na Jetpack Compose.
Został zaprojektowany z myślą o szybkim, reaktywnym interfejsie, wydajności baterii, dynamicznych układach, ułatwieniach dostępu i płynnym wprowadzaniu tekstu.
Gdy nic się nie dzieje, jest w trybie bezczynności i działa w przewidywalny sposób.
W przypadku deweloperów silników gier domena to renderowanie w czasie rzeczywistym, gdzie narzędzia takie jak Godot i Unity sprawdzają się najlepiej.
Nie czekają tylko na interakcję użytkownika.
Działają w pętli ciągłego renderowania i odświeżania.
Może to być 60, 90, 100 klatek na sekundę.
Wykonują obliczenia fizyczne, złożone cieniowanie, animacje szkieletowe i odtwarzają dźwięk przestrzenny, a wszystko to z niesamowitą wydajnością.
Co się stanie, jeśli chcesz połączyć te 2 rozwiązania?
Wcześniej deweloperzy musieli wdrożyć jedno z tych 2 podejść.
Opcja A polega na przepisaniu całej aplikacji w prawdziwym silniku, takim jak Godot czy Unity.
Wyrzucenie istniejącej bazy kodu oznacza utratę dużej części natywnego interfejsu Compose, ponowne wdrażanie podstawowego tekstu i nawigacji oraz pisanie kodu wokół interfejsów API Androida, takich jak zamiana tekstu na mowę.
Opcja B polega na utworzeniu od podstaw potoku renderowania grafiki 3D niskiego poziomu w aplikacji natywnej przy użyciu nieprzetworzonych interfejsów API Vulkan i OpenXR.
Zamiast dostarczać funkcje produktu, tworzysz własny silnik.
Dlatego dzisiaj pokażę, jak najlepiej wykorzystać Godot i Unity jako biblioteki.
Dzięki dodaniu kontenerów przestrzennych możesz teraz korzystać z obu tych rozwiązań.
Możemy to osiągnąć, umieszczając silnik działający w czasie rzeczywistym bezpośrednio w bibliotece w aplikacji Jetpack.
Zachowujesz strukturę produkcyjną aplikacji na Androida, logikę biznesową i płynne renderowanie treści przestrzennych 3D w aplikacji.
Oznacza to również, że możesz mieć jeden plik APK dla wszystkich formatów urządzeń.
Jeśli nie chcesz wyświetlać treści 3D, możesz nie wczytywać biblioteki do aktywności lub fragmentu i zachować przejrzystość bazy kodu, co pozwoli Ci obsługiwać wiele urządzeń.
Filozofia jest prosta: oddzielić aplikację i uczynić z silnika czasu rzeczywistego bibliotekę.
Zamiast wybierać między aplikacją na Androida a silnikiem, traktujesz silnik jako wbudowaną bibliotekę oprogramowania, nazywaną też zależnością AAR.
Możesz pobrać go bezpośrednio do kompilacji Gradle, tak jak każdą inną bibliotekę.
Natywny kod Androida pozostaje głównym elementem aplikacji, która hostuje komponenty 3D.
Jetpack Compose XR zajmuje się tym, co robi najlepiej: renderowaniem natywnego, dostępnego i płynnego interfejsu oraz zarządzaniem stanem aplikacji.
Gdy potrzebny jest element 3D, aplikacja udostępnia osadzony widok renderowania, który jest renderowany przez sam silnik gry.
Dzisiaj szczegółowo omówimy, jak to działa w przypadku silnika Godot.
Zacznę od mechanizmu osadzania i nawiązywania dwukierunkowej komunikacji między Kotlinem a GDScript.
Następnie przyjrzymy się nowemu rozszerzeniu OpenXR, Spatial Container, które naprawdę otworzy drogę do nowej generacji wielozadaniowości i układu przestrzennego.
Przyjrzyjmy się bliżej wersji demonstracyjnej, którą pokazałem wcześniej.
Do stworzenia tej gry użyłem Godot i Android Studio.
Ten klip pokazuje, jak Jetpack Compose for XR renderuje natywne panele przestrzenne interfejsu 2D na potrzeby tablicy wyników, powiadomień i najciekawszych momentów.
Oprócz tego Godot renderuje symulację meczu piłki nożnej.
Tutaj możesz zobaczyć zestawienie informacji o tym, jak komunikujemy się z Kotlinem w GDScript.
W tym celu Godot udostępnia architekturę wtyczek.
Piszesz lekką klasę Kotlin, która rozszerza GodotPlugin.
Ta klasa służy jako interfejs między JVM a środowiskiem wykonawczym C++ Godot.
Dzięki temu interakcje między aplikacją a Godotem są bardzo proste.
Architektura Godot obsługuje komunikację dwukierunkową między Godot i Kotlinem.
Gdy w Godot wystąpi zdarzenie w grze, np. gol lub faul, Godot może bezpośrednio wywołać funkcje Kotlin i aktualizować interfejs Compose w czasie rzeczywistym.
Gdy użytkownik wejdzie w interakcję z odtwarzaczem multimedialnym, np. wstrzyma odtwarzanie lub wybierze inne najciekawsze momenty, Kotlin wyśle sygnał z powrotem do Godota i zostaną wykonane odpowiednie działania silnika gry.
Po przekroczeniu wtyczki Godot wydajność i zarządzanie po prostu spadają wzdłuż linii domeny.
Stosuj sprawdzone metody dotyczące aplikacji na Androida i projektów Godot.
Teraz przyjrzymy się architekturze.
Zobaczmy, jak połączyć to z Godotem w aplikacji na Androida.
Środowisko wykonawcze silnika Godot jest rozpowszechniane przez Maven jako AAR.
Integracja z kompilacją odbywa się zgodnie ze standardowymi praktykami dotyczącymi Androida.
Jest jeden ważny krok, o którym należy pamiętać od razu, a który nazywam pułapką komponentów.
androidResources {
ignoreAssetsPattern = "!.svn:!.git:!.gitignore:!.ds_store:!*.scc:<dir>_*:!CVS:!*~:!android:!export_presets.cfg"
}W projekcie Godot sceny i pliki konfiguracji projektu są przechowywane razem.
W przypadku osadzania silnika Godot projekt Godot jest dodawany bezpośrednio do folderu komponentów.
Oznacza to, że wszystkie zaimportowane siatki 3D, skompresowane tekstury i skompilowane tekstury są przechowywane jako artefakt środowiska wykonawczego w ukrytym pliku o nazwie .godot.
Domyślnie Android usuwa ten folder podczas kompilacji, więc ignoruje każdy katalog z kropką.
Jeśli więc zsynchronizujesz i utworzysz aplikację w normalny sposób, pakiet APK zostanie utworzony, ale ulegnie awarii i nie zostanie wczytany, ponieważ te elementy nie zostały w nim skompilowane.
Aby to naprawić, możesz po prostu zastąpić opcje AAPT w pliku build.gradle i zezwolić na kropki na początku w Godot.
Gdy to zrobisz, możesz raz uruchomić scenę Godot w edytorze Godot, a następnie utworzyć wszystko inne w Android Studio.
Aby obsługiwać kontenery przestrzenne i najnowszą wersję OpenXR, zalecam używanie silnika Godot w wersji 4.8 lub nowszej, rozszerzeń dostawcy Godot w wersji 5.2 i narzędzia Khronos OpenXR loader w wersji 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")Jak Godot i Compose renderują zasoby po ich spakowaniu?
Biblioteka Godot udostępnia metodę fabryczną o nazwie 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 }
)
}Zwraca to obiekt SurfaceView, który Godot po prostu renderuje.
Możesz więc umieścić go w funkcji typu „composable”, a Godot może teraz zmieniać rozmiar, dodawać dopełnienie i umieszczać go w dowolnym miejscu w panelu encji.
Subspace {
SpatialPanel(
modifier = SubspaceModifier
.width(960.dp)
.height(540.dp)
.offset(z = (-200).dp)
.movable()
,
) {
GodotView(
godotHost = godotHost,
modifier = Modifier.fillMaxSize(),
)
}
}Sam silnik nie może zarządzać cyklem życia.
@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)
}
}
}Dlatego aktywność musi implementować interfejs GodotHost.
Dzięki temu wszystkie cykle życia, które występują w ramach aktywności, np. wstrzymanie czy wznowienie, będą wysyłane do silnika Godot, który będzie teraz uwzględniać cykl życia.
Następnie musimy dodać naszą aplikację natywną i silnik, aby zapewnić bezproblemową komunikację.
W tym celu możemy użyć interfejsu GodotHost.
class MainActivity : ComponentActivity(), GodotHost {
override fun getHostPlugins(engine: Godot): Set<GodotPlugin> =
setOf(SoccerGodotPlugin(engine))
}Możemy zdefiniować wtyczkę Godot i upewnić się, że po uruchomieniu Godota wtyczka jest zarejestrowana po stronie Compose.
Wtyczka jest już zarejestrowana, więc skonfigurujmy Androida do komunikacji przez wysyłanie sygnału do Godota.
W kodzie Kotlin utworzymy instancję GodotPlugin.
Zarejestrujemy wtyczkę w bibliotece Godot za pomocą aktywności.
Następnie w Kotlin tworzymy GodotPlugin, rejestrujemy jego nazwę i przekazujemy Godotowi wszystkie sygnały, których chcemy używać w tym pakiecie.
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),
)
}Następnie mamy skrypt Godot, w którym możesz uzyskać instancję tej wtyczki.
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)Możemy zobaczyć, co jest zarejestrowane, a następnie połączyć funkcje z zarejestrowanymi sygnałami.
Gdy wywołasz coś takiego jak emitSignal, możesz przekazać to z kodu Kotlin i trafi to bezpośrednio do Godota.
class SoccerGodotPlugin(godot: Godot) : GodotPlugin(godot) {
fun sendCommandToGodot(command: String) {
emitSignal("match_command", command)
}
}Aby Godot mógł komunikować się z Kotlinem, np. gdy padnie gol, możemy wywołać funkcję Kotlina bezpośrednio ze skryptu Godota, używając instancji wtyczki.
func _on_goal_scored(minute: int, home: int, away: int) -> void:
if soccer_plugin:
soccer_plugin.onMatchEvent("GOAL", minute, home, away)Najpierw dodaj do funkcji Kotlin adnotację 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)
}
}Silnik obsługuje przekształcanie JNI w tle.
Następnie możemy wywołać funkcję bezpośrednio z Godot Script.
Podczas korzystania z tego operatora warto pamiętać o jednej wskazówce: UsedByGodot wymaga dokładnego dopasowania ciągu znaków.
Jeśli więc skrypt Godot wywołuje onMatchEvent, nazwa w Kotlin musi być dokładnie taka sama, ponieważ nie ma automatycznej konwersji na camelCase ani snake_case.
Musisz więc upewnić się, że obie metody są dokładnie takie same.
Po połączeniu wtyczki najważniejszą częścią tej prezentacji jest to, co nazywam modelem mentalnym.
W tym miejscu silniki gier i Jetpack Compose działają na 2 zasadniczo przeciwstawnych architekturach.
Silnik gry działa w trybie ciągłego cyklu klatek.
Niezależnie od tego, czy coś zmienisz, czy nie, proces cyklu silnika gry odbywa się 60, 90, 100 razy na sekundę, zapewniając bogate i płynne renderowanie w czasie rzeczywistym.
Silniki takie jak Godot efektywnie wykorzystują cykle GPU i CPU.
Z kolei Jetpack Compose obsługuje elementy reaktywnie i jest oparty na stanie.
A gdy aplikacja się nie zmienia, Compose dosłownie kontynuuje działanie, zużywając 0% procesora.
Jeśli chcesz połączyć te 2 środowiska wykonawcze bez jasnej strategii, te filozofie mogą się ze sobą kolidować.
Zobaczmy, jak możemy rozwiązać ten problem za pomocą czegoś, co nazywam „wyciekiem cyklu”.
Tutaj mamy element 3D, na przykład zegar tablicy wyników.
W skrypcie silnika znajduje się zmienna licznika czasu, która stale się zmienia.
Jeśli bezmyślnie przekażesz tę zmienną do Compose, powiedzmy 90 razy na sekundę, wejdziesz w pętlę rekompozycji 90 razy na sekundę.
Oznacza to, że Compose będzie ponownie oceniać elementy kompozycyjne, mierzyć układy i rysować drzewa interfejsu 90 razy na sekundę.
Będziesz widzieć gubienie klatek, opóźnienia interfejsu i szybko doświadczysz dławienia termicznego.
Złota zasada brzmi: nigdy nie pozwól, aby pętla silnika ciągłego dyktowała pętlę interfejsu reaktywnego.
W tym przykładzie zamiast ciągłego aktualizowania licznika czasu na tablicy wyników przez Godota, Android pobiera te dane raz na sekundę, gdy są potrzebne.
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, ...) }
}Na koniec pamiętaj o granicy wątku.
Pętla silnika gry Godot jest stale wykonywana we własnym wątku renderowania.
Interfejsy użytkownika Androida muszą być wykonywane w głównym wątku Androida.
Dlatego przed zaktualizowaniem stanów modelu widoku zawsze upewnij się, że wtyczka Kotlin wysyła zdarzenia za pomocą Dispatchers.Main lub elementów podstawowych stanu bezpiecznego.
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
)
}Dzięki zachowaniu pracy o wysokiej częstotliwości w silniku i odrębnej semantyki w Compose możesz uzyskać płynne widoki 3D bez wpływu na czas pracy na baterii, ułatwienia dostępu czy wydajność aplikacji natywnej.
Teraz zobaczmy, jak dodać kontenery przestrzenne do projektu Godot.
Najpierw skonfigurujemy aplikację tak, aby działała w trybie pełnej przestrzeni, włączymy kontenery przestrzenne ze wskazówkami dotyczącymi granic, a następnie ustawimy bibliotekę programu ładującego XR w pliku manifestu Androida.
<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 ma własne ustawienia projektu XR, w których mogę włączyć OpenXR, shadery XR i kontenery przestrzenne bezpośrednio w edytorze.
To w zasadzie wszystko, co musisz zrobić, aby włączyć kontenery przestrzenne w Godot.
Teraz, gdy Godot działa z Androidem XR, kontenerami przestrzennymi i wszystkimi tymi funkcjami, zalecamy zapoznanie się z najlepszymi praktykami dotyczącymi korzystania z kontenerów przestrzennych, paneli przestrzennych i JXR na stronie dla deweloperów.
Właśnie omówiliśmy przykład użycia kontenerów przestrzennych w silniku Godot i Androidzie XR. Jestem pewien, że wielu z Was chciałoby usłyszeć opinię dewelopera, który aktywnie pracuje nad kontenerami przestrzennymi na Androida XR.
Teraz chciałbym zaprosić Josha i Rodrigo z NBA, którzy są jednymi z pierwszych partnerów korzystających z kontenerów przestrzennych w swojej aplikacji.
Dziękuję, Luke.
Cześć wszystkim.
Nazywam się Josh i jestem programistą aplikacji na Androida w NBA.
Jestem tu dzisiaj z moim współpracownikiem, Rodrigo Sanchezem, programistą oprogramowania Unity w firmie Beyond Sports.
Dzisiaj chcemy pokazać, jak wykorzystaliśmy Unity as a Library do stworzenia widoku boiska w aplikacji NBA.
W NBA zawsze staramy się zapewniać fanom nowe wrażenia.
Dlatego we współpracy z Google i Beyond Sports z przyjemnością przedstawiamy widok boiska.
Widok boiska oferuje trójwymiarowe odtworzenie meczów NBA, dzięki czemu subskrybenci League Pass mogą oglądać je z nowych perspektyw, które nie są dostępne w przypadku zwykłych transmisji wideo.
Zmień rozmiar i obróć kort, a następnie umieść go w dowolnym miejscu w przestrzeni.
Możesz poruszać się po nim i oglądać grę z różnych perspektyw.
Przez cały czas możesz normalnie korzystać z aplikacji NBA, od wyświetlania statystyk synchronizacji po oglądanie transmisji na żywo.
Jak to wszystko skonfigurować?
Widok kortu jest renderowany w Unity i wykorzystywany przez naszą aplikację na Androida, która używa Unity jako biblioteki.
Następnie tworzymy jedno archiwum Androida, hostowane w Maven Central, które aplikacja NBA może pobrać jako pojedynczą zależność.
Dzięki temu mogliśmy podzielić pracę między Androida i Unity.
Dzięki temu każdy z nas może skupić się na tym, co zna najlepiej, i pracować równolegle, aby przyspieszyć ogólny rozwój.
Na przykład gdy nasz zespół pracował nad pełną przestrzenią w Compose, zespół Unity mógł skupić się na skalowaniu i układzie boiska 3D.
Teraz Rodrigo opowie więcej o projekcie Unity i o tym, jak wykorzystano Unity jako bibliotekę.
Dziękuję, Josh.
W Beyond Sports od kilku lat współpracujemy z NBA nad tworzeniem wizualizacji 3D na podstawie rzeczywistych danych z meczów.
W ramach tego projektu dostosowaliśmy naszą symulację 3D, aby była zgodna z Androidem XR.
Nasza symulacja jest oparta na silniku Unity z C#.
Eksportujemy symulację do Androida z Unity jako biblioteką i tworzymy obok niej warstwę komunikacyjną za pomocą Kotlina.
Zanim połączymy te 2 moduły w jedną zależność Maven, którą NBA może uwzględnić w swojej aplikacji.
Na ekranie widać krótki film z tej samej symulacji, którą Josh pokazał wcześniej, ale uruchomionej w edytorze.
Aby to zrobić, używamy pakietu rozszerzeń Androida XR dla Unity.
Przeprowadzę Cię przez ten proces.
Zanim więc zaczniesz tworzyć w Unity, upewnij się, że masz wybrane odpowiednie ustawienia i wtyczki.
Pobierz pakiet XR Plug-in Management od Unity i pakiet Android XR Extensions for Unity od Google.
Skonfiguruj je w ustawieniach zarządzania wtyczkami XR, aby używać dostawcy rozszerzeń Androida XR OpenXR.
W naszym przypadku chcieliśmy użyć kontenerów przestrzennych, więc włączyliśmy tę funkcję i wyłączyliśmy immersyjną rzeczywistość rozszerzoną w ustawieniach zarządzania sesją.
Nie użyliśmy żadnego pakietu danych wejściowych XR, ponieważ wszystkie dane wejściowe są obsługiwane natywnie, poza Unity.
Następnie w ustawieniach odtwarzacza musisz ustawić projekt tak, aby używał minimalnego poziomu API 24 lub wyższego, a punkt wejścia aplikacji musi być ustawiony na nowszą aktywność w grze.
Pamiętaj, że pole Aktywność musi pozostać niezaznaczone.
Interesuje nas tylko kompilacja na architekturę ARM64, więc wybieramy tę architekturę docelową, a w sekcji rozdzielczości włączamy opcję uruchamiania w tle, aby można było wykonywać wiele zadań jednocześnie w innych aplikacjach i nadal mieć uruchomioną symulację 3D.
Włączamy też aktywność z możliwością zmiany rozmiaru, ponieważ jest ona wymagana do renderowania okien, takich jak prośby o uprawnienia systemowe.
Na koniec zalecamy renderowanie aplikacji na Androida XR za pomocą uniwersalnego potoku renderowania i interfejsu Vulkan Graphics API.
W zasobie URP należy wyłączyć HDR i przetwarzanie końcowe.
Po wykonaniu wszystkich tych czynności możesz utworzyć kompilację.
Przejdź do okna kompilacji. Jeśli używasz Unity 6.1 lub nowszej wersji, możesz wybrać profil kompilacji Androida XR, który umożliwia rozdzielenie ustawień, jeśli tworzysz też aplikacje na urządzenia mobilne z Androidem.
Przed utworzeniem upewnij się, że eksportowanie projektu jest włączone.
Po zakończeniu kompilacji w Unity otrzymasz działający projekt aplikacji na Androida, który można skompilować w Android Studio i uruchomić na urządzeniu jak każdy inny plik APK Unity.
W Android Studio musisz przekształcić wyeksportowaną kompilację w bibliotekę, która będzie używana przez inną aplikację.
// 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")
}
}W naszym projekcie mamy 4 różne moduły: główny moduł APK do testowania jako obiekt zastępczy makiety aplikacji NBA, moduł Unity Library zawierający wyeksportowaną kompilację, moduł nativeCommunications zawierający warstwę komunikacji utworzoną między aktywnościami oraz moduł packageExporter, którego używamy do scalania plików AAR w jeden pakiet do dostarczenia do Mavena.
Scalanie wielu plików AAR można przeprowadzić za pomocą biblioteki Android Fused Library lub starszych metod społeczności, takich jak Fat AAR.
Możesz zachować prostą strukturę i komunikację, utrzymując Unity i główną aktywność w tym samym procesie.
Zrezygnowaliśmy z tego i postanowiliśmy oddzielić aktywność Unity w osobny proces, aby zabezpieczyć się na wypadek awarii części Unity. Dzięki temu aplikacja NBA nie zostanie dotknięta awarią i będzie mogła bezpiecznie zamknąć Unity bez awarii.
Ta decyzja zmusiła nas do przyjęcia bardziej niezawodnego systemu komunikacji między procesami.
Ponieważ aktywności są w różnych procesach, nie mają wspólnej pamięci, a wiele typowych metod IPC na Androidzie nie działa lub nie spełnia wymagań.
Do komunikacji między główną aktywnością NBA a wbudowaną aktywnością Unity używamy języka definiowania interfejsu Androida (AIDL).
Komunikacja między Androidem a Unity również opiera się na interfejsach, wykorzystując wbudowaną w Unity klasę AndroidJavaProxy do rejestrowania interfejsu Kotlin w implementacji interfejsu C#.
Nasz moduł komunikacyjny skupia się głównie na tym, co nazywamy mostem aktywności.
// 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) }
}
}Obie aktywności mają dostęp do mostu aktywności i zawartych w nim metod użytkowych.
Most zawiera odniesienie do usługi, która implementuje zdefiniowane przez nas interfejsy AIDL. Zapewnia też łatwy sposób powiązania aktywności z procesem, przepraszam, z usługą, i zarządza wewnętrznie jej cyklem życia.
Symulację można rozpocząć za pomocą wywołania zwrotnego, w którym jako parametr przekazujemy identyfikator meczu.
Następnie mostek przekazuje go przez AIDL, a potem do aplikacji Unity, gdzie C# otrzymuje polecenie i rozpoczyna konfigurowanie sceny.
public class NativeInterfaceListener : AndroidJavaProxy {
[UsedImplicitly]
public void startMatch(string matchID) {
UnityThreadHelper.InvokeOnMain(() => _internalAPI.StartMatch(matchID));
}
}Musimy poczekać, aż Unity będzie gotowy do naciśnięcia poleceń, które mu przekazujemy.
Aktywność Unity, która opakowuje aplikację, będzie aktywna, zanim Unity zostanie uruchomione.
Polecenia wysłane zbyt wcześnie są przechowywane w kolejce wywołań zwrotnych.
fun onStartMatchClickedInternal() {
if (isServiceBound) {
AIDLBridgeService.startMatch("match_id")
} else {
delayedCallbacksQueue.add(action)
}
}Po zakończeniu ładowania i inicjowania Unity, gdy stan jest bezpieczny, przekazujemy z C# informację, że Unity jest gotowe.
// After bootload and initialization
using AndroidJavaClass nativeBridge = new("com.company.UnityActivity");
using AndroidJavaObject instance = nativeBridge.GetStatic<AndroidJavaObject>("instance");
instance.Call("onUnityReady");Następnie przechodzimy przez kolejkę i przekazujemy wszystkie zapisane polecenia.
@Suppress("unused") // Unity implicit call
fun onUnityReady() {
bridge.activityBridgeServiceInternal?.onUnityReady()
}
delayedCallbacksQueue.forEach { it(activityBridgeService) }Ten diagram lepiej ilustruje, jak działa nasza komunikacja między procesami.
Jak widać, za pomocą mostu między aktywnościami możemy obsługiwać wspomniane wywołania zwrotne.
Skonfigurowaliśmy StateFlows, które są aktualizowane z Unity, aby monitorować wartości takie jak stan uwierzytelniania czy sygnatura czasowa bieżącego stanu dopasowania.
Zarządzamy też szyfrowaniem danych logowania i przekazujemy wszelkie błędy z Unity, aby NBA mogła łatwiej je debugować i decydować, jak najlepiej je obsługiwać na poziomie aplikacji.
To wszystko na ten temat.
Teraz oddam głos Joshowi, który opowie więcej o tym, jak zintegrowali pakiet Court View Unity z aplikacją NBA.
Używamy tych zależności Gradle, aby zintegrować Unity jako bibliotekę z naszą aplikacją Jetpack Compose: środowisko wykonawcze XR do uzyskiwania dostępu do sesji 3D, Jetpack SceneCore do hostowania aktywności Unity i przełączania środowisk przestrzennych oraz Jetpack Compose for XR do uzyskiwania dostępu do paneli przestrzennych w naszym kodzie 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")
}Teraz zobaczmy, jak renderujemy widok boiska w 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)
}
}
}Zanim uruchomimy widok kortu, przeniesiemy Cię do trybu pełnej przestrzeni.
Jest to wymagane do wyświetlania panelu przestrzennego z Unity.
Następnie wysyłamy informacje o grze przez AIDL, aby uruchomić widok kortu w przypadku konkretnej gry.
Następnie uruchamiany jest silnik Unity.
Nasz archiwum Androida wysyła to jako intencję do aktywności Unity, którą opakowujemy w SpatialActivityPanelController.
Następnie przekazujemy ten kontroler do SpatialActivityPanel.
Ten panel zawiera widok kortu i jest udostępniany w naszej podprzestrzeni z naszym głównym panelem przestrzennym.
Dzięki temu możemy bez problemu sterować obydwoma urządzeniami jednocześnie.
To wszystko.
Odeślę go do Luke'a.
Bardzo dziękuję.
Jak widać, NBA udało się przyspieszyć rozwój Court View dzięki rozdzieleniu prac nad Unity i Jetpack Compose.
Więcej informacji o tym, jak zacząć korzystać z kontenerów przestrzennych i używać ich we własnych aplikacjach na Androida XR, znajdziesz w dokumentacji dla deweloperów na stronie developer.android.com/develop/xr.
Dziękujemy za udział w dzisiejszej sesji. Nie możemy się doczekać, aby zobaczyć, co stworzycie.
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)
}
}
}