Android 主任工程師
瞭解如何將 3D 遊戲引擎 (Godot 和 Unity) 與 Jetpack Compose 整合至單一程式碼集,用於行動裝置和 Android XR。我們將探討如何將 3D 場景嵌入空間容器、設定 Kotlin 與引擎之間的通訊,並獨家介紹 NBA 如何在 Android XR 上打造即時籃球體驗。
GodotPlugin 子類別會處理與 Kotlin 的雙向通訊。轉錄稿
大家好。
我是 Luke Hopkins。
我是 Android XR 團隊的開發人員關係工程師。
今天我們將討論如何將 Godot 和 Unity 等遊戲引擎嵌入 Android 專案。
稍後,NBA 和 Beyond Sports 的 Josh 和 Rodrigo 將加入我們的行列,討論他們如何將 Unity 做為程式庫導入 NBA 應用程式。
在本課程中,我會深入探討如何將 Godot 整合至 Jetpack Compose 應用程式,接著 Josh 和 Rodrigo 會介紹他們的 NBA 應用程式。
Android XR 是 Android 生態系統的延伸,可擴展至各種板型規格,將你熟悉且喜愛的體驗帶入全新境界。
我們提供直覺化的方式,讓您將現有的 XR 和 Android 應用程式帶入這個新空間,並運用您目前已在使用的 Android 技術。
Android XR 是專為各種裝置設計的整合式平台。
Android XR 裝置可提供不同類型的體驗。
音訊和顯示型眼鏡提供擴增體驗,XR 頭戴式裝置和有線 XR 眼鏡則提供沉浸式體驗。
今天,我們將討論如何為有線 XR 眼鏡和 XR 頭戴式裝置打造沉浸式體驗。
如要打造沉浸式體驗,可以使用 Jetpack XR SDK,或搭配即時引擎使用 OpenXR。
我們支援 Unity、Godot 和 Unreal,無論您使用哪種工具,都能為 Android 打造沉浸式體驗。
在這場演講中,我將說明如何運用 Jetpack 的 SceneCore API、Godot 和新的 OpenXR 擴充功能,實現這項目標。
您可以看到 Godot 與 Jetpack 的空間面板並排執行。
請注意,Godot 會算繪為空間內容,在空間容器中提供 3D 體積和 2D Compose UI。
這樣一來,開發人員就能使用自選的即時引擎執行物理模擬和自訂著色器,同時將原生 Android 開發做法套用至應用程式的其他部分。
有了 Android XR 和 XREAL AURA,您也能打造類似體驗。
在這場講座中,我們會說明如何使用 Godot 和 Android Studio 建構這類體驗,並邀請 NBA 和 Beyond Sports 的合作夥伴,分享他們如何使用 Unity 做為程式庫來建構這類體驗。
過去,使用 OpenXR 建構 XR 體驗,代表建構的體驗會轉譯每個像素。
也就是說,如果開發人員需要顯示第二個活動,就無法同時顯示兩個活動。
為解決這個問題,OpenXR 工作群組 Khronos 與多位業界領導者合作,發布了 OpenXR 空間容器擴充功能。
您可以將空間容器視為裝在盒子裡的 OpenXR。
引擎不會擁有整個天空盒,而是會獲得系統管理的 3D 空間,可與 Jetpack 的 XR 空間面板、其他空間容器和標準 Android 活動並存。
假設您想在執行沉浸式遊戲時,同時顯示兩個空間面板,充分運用 Android XR。
您可以在引擎中算繪所有內容,因此在這些用途中,Godot 編輯器和空間容器可以同時執行。
自 Spatial API 級別 4 起,Google 已在 Android XR 中導入空間容器。
Spatial Containers 支援兩種重要的顯示模式。
首先是定界模式。
這會提供定義的 3D 體積,並內建多個避開功能。
使用者可以抓取、旋轉、定位、縮小空間容器,而且支援立體深度投影,讓您在裁剪容器邊緣時,也能享有神奇視窗或 3D 彈出效果。
第二種是無界限模式。
如果使用者決定要完全沉浸在體驗中,應用程式可以要求從受限模式順暢轉換至全螢幕模式,然後也可以返回受限模式。
Android XR 推出空間容器,為開發人員帶來更多新穎、廣泛的構想。
假設您想將 Android UI 帶入遊戲,或想建立自訂著色器,在應用程式中顯示 3D 內容。
在推出空間容器之前,Compose 和遊戲引擎無法同時執行。
在即時引擎中開發應用程式時,我們也有效運用了兩種不同的模式。
對於原生 Android 開發人員,應用程式執行階段是以 Jetpack Compose 為基礎建構。
這項工具專為快速、反應靈敏的使用者介面、電池效率、動態版面配置、系統無障礙功能和流暢的文字輸入而設計。
沒有事件發生時,系統會處於閒置狀態,而且可以預測。
對遊戲引擎開發人員來說,即時算繪是 Godot 和 Unity 等工具大放異彩的領域。
不會只等待使用者互動。
會持續執行 tick 和轉譯迴圈。
因此,影格率可能是每秒 60、90、100 影格。
他們會進行物理計算、複雜的著色器、骨骼動畫、空間音訊,而且所有這些都具有令人難以置信的效能。
如果想兼顧兩者,該怎麼做?
過去,開發人員必須實作這兩種方法之一。
選項 A 是在 Godot 或 Unity 等實際引擎中重寫整個應用程式。
如果捨棄現有的程式碼集,就表示您會失去許多原生 Compose UI、必須重新實作基本文字和導覽功能,以及編寫 Android API (例如文字轉語音) 的包裝函式。
選項 B 是使用原始 Vulkan 和 OpenXR API,在原生應用程式中從頭建構低階 3D 圖形算繪管道。
您基本上是在建構專屬引擎,而不是提供產品功能。
今天,我將示範如何充分運用 Godot 和 Unity 程式庫。
現在,有了空間容器,您就能兩全其美。
只要將即時引擎直接嵌入 Jetpack 應用程式的程式庫,就能達成這個目標。
您可以保留正式版 Android 應用程式結構、商業邏輯,以及應用程式內 3D 空間內容的流暢算繪。
這也表示您可以為所有板型規格提供一個 APK。
如果不想只顯示 3D 內容,可以選擇不將程式庫載入活動或片段,並保持程式碼集清晰,以便支援多種裝置。
這個理念很簡單,就是將應用程式解耦,並將即時引擎設為程式庫。
您不必在 Android 應用程式或引擎之間做出選擇,而是將引擎視為嵌入式軟體程式庫,也就是 AAR 依附元件。
您可以直接將這個程式庫提取到 Gradle 建構作業中,就像其他程式庫一樣。
Android 原生程式碼仍是託管 3D 元件的應用程式核心。
Jetpack Compose XR 負責處理最擅長的工作,也就是轉譯原生無障礙流暢 UI,以及管理應用程式狀態。
需要 3D 元素時,應用程式會託管由遊戲引擎本身算繪的嵌入式算繪檢視畫面。
今天,我們將深入探討 Godot 的運作方式。
首先,我會介紹在 Kotlin 和 GDScript 之間嵌入及建立雙向通訊的機制。
接著,我們會介紹新的 OpenXR 擴充功能 Spatial Container,這項功能將真正開啟新一代的多工處理和空間版面配置。
我們來快速回顧一下先前的示範。
我是用 Godot 和 Android Studio 製作的。
這段短片顯示 Jetpack Compose for XR 轉譯原生 2D UI 空間面板,用於顯示計分板、通知和精華片段。
此外,Godot 還會算繪足球比賽的模擬畫面。
以下詳細說明如何從 Kotlin 與 GDScript 進行通訊。
為此,Godot 提供外掛程式架構。
您編寫了擴充 GodotPlugin 的輕量 Kotlin 類別。
這個類別是 JVM 和 Godot C++ 執行階段之間的介面。
因此,應用程式與 Godot 之間的互動非常輕量。
Godot 的架構支援 Godot 和 Kotlin 之間的雙向通訊。
Godot 發生遊戲事件 (例如進球或犯規) 時,可直接叫用 Kotlin 函式,並即時更新 Compose UI。
當使用者與媒體播放器互動時 (例如暫停或選取不同的精華片段),Kotlin 會向 Godot 發出信號,並執行對應的遊戲引擎動作。
Godot 外掛程式整合完成後,效能和管理作業就會依網域劃分。
請遵循 Android 應用程式和 Godot 專案的最佳標準做法。
接著來看看架構。
接著來看看如何將 Godot 連結至 Android 應用程式。
Godot 引擎執行階段會透過 Maven 以 AAR 形式發布。
只要按照標準 Android 做法,即可整合至建構作業。
首先,請務必留意一個重要步驟,我稱之為「素材資源陷阱」。
androidResources {
ignoreAssetsPattern = "!.svn:!.git:!.gitignore:!.ds_store:!*.scc:<dir>_*:!CVS:!*~:!android:!export_presets.cfg"
}在 Godot 專案中,場景和專案設定檔會放在一起。
嵌入 Godot 時,Godot 專案會直接新增至資產資料夾。
這表示所有匯入的 3D 網格、壓縮紋理和編譯紋理,都會以執行階段構件的形式,儲存在名為 .godot 的隱藏檔案中。
根據預設,Android 會在建構時移除這個資料夾,因此會忽略任何含有點號的目錄。
因此,如果您正常同步及建構應用程式,APK 會建構,但會當機並載入,因為這些內容尚未編譯到 APK 中。
因此,如要修正這個問題,只要在 build.gradle 中明確覆寫 AAPT 選項,並允許 Godot 產生開頭點即可。
完成後,您可以在 Godot 編輯器中執行 Godot 場景,然後在 Android Studio 中建構其他內容。
為了支援空間容器和最新 OpenXR,建議使用 Godot 4.8 以上版本、Godot 供應商擴充功能 5.2,以及 Khronos OpenXR 載入器 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")資產封裝完成後,Godot 和 Compose 實際上如何算繪?
Godot 程式庫提供名為 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 }
)
}這會傳回 Godot 轉譯的 SurfaceView。
因此,您可以使用可組合函式包裝,現在可以調整 Godot 的大小、邊框間距,並放置在實體面板中的任何位置。
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)
}
}
}因此,您的活動需要實作 GodotHost 介面。
這可確保活動中發生的任何生命週期 (暫停、繼續) 都會傳送至 Godot 引擎,現在引擎已具備生命週期感知功能。
接下來,我們需要新增原生應用程式和引擎,以便順暢通訊。
因此,我們可以使用 GodotHost 介面來執行這項操作。
class MainActivity : ComponentActivity(), GodotHost {
override fun getHostPlugins(engine: Godot): Set<GodotPlugin> =
setOf(SoccerGodotPlugin(engine))
}我們可以定義 Godot 外掛程式,並確保 Godot 啟動時,外掛程式會從 Compose 端註冊。
現在外掛程式已註冊,接著要設定 Android,讓它向 Godot 發出信號來進行通訊。
在 Kotlin 程式碼中,我們將建立 GodotPlugin 的例項。
我們將讓活動向 Godot 程式庫註冊外掛程式。
接著在 Kotlin 中建立 GodotPlugin、註冊名稱,並告知 Godot 我們想在這個套件中使用的所有信號。
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),
)
}接下來是 Godot 指令碼,您可以在這裡取得這個外掛程式的執行個體。
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)我們可以看到已註冊的內容,然後在這裡將函式連結到已註冊的信號。
因此,只要呼叫 emitSignal 等函式,就能從 Kotlin 程式碼傳遞這個函式,直接傳送至 Godot。
class SoccerGodotPlugin(godot: Godot) : GodotPlugin(godot) {
fun sendCommandToGodot(command: String) {
emitSignal("match_command", command)
}
}如要讓 Godot 與 Kotlin 進行通訊,假設發生進球事件,我們可以直接從 Godot 指令碼呼叫 Kotlin 函式,只要使用外掛程式執行個體即可。
func _on_goal_scored(minute: int, home: int, away: int) -> void:
if soccer_plugin:
soccer_plugin.onMatchEvent("GOAL", minute, home, away)首先,請使用 UsedByGodot 為 Kotlin 函式加上註解。
@UsedByGodot
fun onMatchEvent(type: String, minute: Int, home: Int, away: Int, title: String) {
_uiState.update { state ->
state.copy(homeScore = home, awayScore = away, matchSeconds = minute)
}
}引擎會在背景處理 JNI 封送處理。
接著,我們可以直接從 Godot 指令碼呼叫函式。
使用這項功能時,請務必注意 UsedByGodot 必須與字串完全相符。
因此,如果 Godot 指令碼呼叫 onMatchEvent,Kotlin 名稱必須完全相同,不會自動轉換為駝峰式大小寫或蛇形命名法。
因此,您只需要確保這兩種方法完全相同。
連結外掛程式後,這場演講最重要的部分就是我所謂的心智模型。
這就是遊戲引擎和 Jetpack Compose 在兩個基本架構上運作的方式,就像是相反的架構。
遊戲引擎會持續產生影格。
無論您是否變更任何內容,遊戲引擎的 tick 程序每秒會執行 60、90、100 次,提供豐富流暢的即時算繪。
Godot 等引擎則能有效運用 GPU 和 CPU 週期。
相較之下,Jetpack Compose 則會以回應方式處理,且由狀態驅動。
當應用程式沒有變更時,Compose 會繼續執行,但不會耗用 CPU。
如果想在沒有明確策略的情況下,將這兩者連結並執行兩個執行階段,這些理念往往會相互衝突。
接下來,我們來看看如何透過我所謂的「tick 洩漏」來解決這個問題。
我們可以在這裡建立 3D 體驗,例如計分板計時器。
在引擎指令碼中,有一個 ti- 計時器變數會持續變更。
如果盲目地將這個變數轉送至 Compose,假設每秒 90 次,您就會每秒進入這個重新組合迴圈 90 次。
也就是說,Compose 會重新評估可組合函式、重新測量版面配置,並每秒重新繪製 UI 樹狀結構 90 次。
這會導致影格遺失、使用者介面延遲,而且很快就會達到熱調節。
因此,黃金法則是絕不讓持續引擎迴圈決定回應式 UI 迴圈。
在這個範例中,我不會讓 Godot 持續更新計分板計時器,而是確保 Android 在需要時每秒提取一次這項資料。
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, ...) }
}最後,請務必記住執行緒邊界。
Godot 遊戲引擎迴圈會持續在自己的算繪執行緒上執行。
Android UI 必須在 Android 主執行緒上執行。
因此,請務必確保 Kotlin 外掛程式使用 Dispatchers.Main 或安全狀態原始物件調度事件,然後再更新檢視模型狀態。
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
)
}只要將高頻率工作保留在引擎中,並在 Compose 中保留離散語意,就能獲得流暢的 3D 檢視區,同時兼顧原生應用程式的電池續航力、無障礙功能或效能。
接著,我們來看看如何將空間容器新增至 Godot 專案。
首先,請將應用程式設為全螢幕模式,啟用空間容器及其邊界提示,然後在 Android 資訊清單檔案中設定 XR 載入器程式庫。
<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 有專屬的 XR 專案設定,只要透過編輯器,就能啟用 OpenXR、XR 著色器和空間容器。
這樣一來,就能在 Godot 中啟用空間容器。
現在您已瞭解如何搭配使用 Godot 與 Android XR、空間容器,以及所有相關開發作業、空間容器的最佳做法,以及空間面板和 JXR,我們強烈建議您前往開發人員網站查看更多資訊。
我們剛才示範了如何在 Godot Engine 和 Android XR 中使用空間容器,相信許多人都想聽聽開發人員的經驗談,瞭解他們如何積極運用空間容器打造 Android XR 體驗。
接下來,我想邀請 NBA 的 Josh 和 Rodrigo,他們是首批在應用程式中使用 Spatial Containers 的合作夥伴之一。
謝謝你,Luke。
大家好!
我是 Josh,NBA 的 Android 開發人員。
今天與我一起的是 Beyond Sports 的 Unity 軟體開發人員 Rodrigo Sanchez。
今天,我們想向各位展示如何使用 Unity 做為程式庫,為 NBA 應用程式打造 Court View。
NBA 一直致力於為球迷帶來全新體驗。
因此,我們很高興與 Google 和 Beyond Sports 合作推出 Court View。
Court View 則提供 3D 重現的 NBA 賽事,讓 League Pass 訂閱者能以全新視角觀看比賽,享受一般轉播影片無法提供的體驗。
縮放及重新旋轉球場,並放置在空間中的任何位置。
你可以四處移動,從獨特角度觀看比賽。
在此期間,你可以照常使用 NBA 應用程式,包括查看同步統計資料和觀看現場直播。
如何設定這一切?
球場檢視畫面是在 Unity 中算繪,並由 Android 應用程式使用 Unity 做為程式庫來呈現。
接著,我們產生單一 Android 封存檔,並託管於 Maven Central,NBA 應用程式可將其提取為單一依附元件。
這樣一來,我們就能將工作拆分到 Android 端和 Unity 端。
這樣一來,每個人都能專注於自己最擅長的部分,並同時進行工作,加快整體開發速度。
舉例來說,我們的團隊在 Compose 中開發全螢幕體驗時,Unity 團隊可以專注於 3D 球場的縮放和版面配置。
接下來,我將交棒給 Rodrigo,請他進一步介紹 Unity 專案,以及他們如何使用 Unity 做為程式庫。
謝謝 Josh。
Beyond Sports 與 NBA 合作多年,致力於運用比賽的實際資料呈現 3D 視覺效果。
在這項專案中,我們調整了 3D 模擬,使其與 Android XR 相容。
我們的模擬環境是以 Unity 引擎和 C# 建構而成。
我們將模擬匯出至 Android,並將 Unity 做為程式庫,然後使用 Kotlin 在旁邊建構通訊層。
在將這兩個模組組合成單一 Maven 依附元件,供 NBA 納入應用程式之前。
畫面上顯示的短片,就是 Josh 剛才展示的模擬,只是在編輯器中執行。
為此,我們將使用 Unity 的 Android XR 擴充功能套件。
接著來看看實際流程。
因此,在 Unity 中建構前,請務必選取正確的設定和外掛程式。
取得 Unity 的 XR Plug-in Management 套件,以及 Google 的 Android XR Extensions for Unity 套件。
請在 XR 外掛程式管理設定中進行設定,以便使用 OpenXR Android XR 擴充功能供應商。
在我們的用途中,我們想使用空間容器,因此啟用了該功能,並在工作階段管理設定中停用沉浸式 XR。
我們沒有使用 XR 輸入套件,因為所有輸入內容都是在 Unity 外部以原生方式處理。
接著,在播放器設定中,將專案設為使用 24 以上的最低 API 級別,並將應用程式進入點設為較新的 Game Activity。
請注意,請勿勾選「活動」。
我們只想為 ARM64 建構,因此選取該目標架構,並在解析度部分啟用「Run in Background」,這樣就能同時使用其他應用程式,並讓 3D 模擬在背景執行。
我們也會啟用可調整大小的 Activity,因為這是轉譯對話方塊 (例如系統權限要求) 的必要條件。
最後,建議使用通用轉譯管線和 Vulkan Graphics API 轉譯 Android XR 應用程式。
在 URP 資產中,應停用 HDR 和後製處理。
完成上述所有步驟後,就可以開始建構。
前往建構視窗。如果您使用 Unity 6.1 以上版本,可以選取 Android XR 建構設定檔,這樣一來,如果您也建構 Android 行動裝置,就能分隔設定。
建構前,請務必啟用匯出專案。
Unity 完成建構後,您會得到一個可運作的 Android 專案,該專案可在 Android Studio 中組裝,並在裝置上執行,就像任何其他 Unity APK 一樣。
進入 Android Studio 後,您需要將匯出的建構作業轉換為程式庫,供其他應用程式使用。
// 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")
}
}在這個專案中,我們有四個不同的模組:主要 APK 模組,用於測試 NBA 包裝應用程式的模擬預留位置;Unity Library 模組,用於存放匯出的建構作業;nativeCommunications 模組,用於存放我們在活動之間建構的通訊層;以及 packageExporter 模組,用於將 AAR 合併為單一套件,以便傳送至 Maven。
您可以使用 Android Fused Library 或 Fat AAR 等舊版社群方法,合併多個 AAR。
只要讓 Unity 和主要活動在同一個程序中執行,就能簡化結構和通訊。
我們決定不採用這種做法,而是將 Unity 活動分成獨立程序,做為安全防護措施。這樣一來,即使 Unity 部分當機,NBA 應用程式也不會受到影響,而且可以安全地處理關閉 Unity 的作業,不會自行當機。
這項決定迫使我們採用更強大的處理程序間通訊系統。
由於活動位於不同程序,因此不會共用記憶體,而且許多常見的 Android IPC 方法都無法運作或不符合需求。
我們使用 Android 介面定義語言 (AIDL),在 NBA 的主要活動和嵌入的 Unity 活動之間進行通訊。
接著,Android 和 Unity 之間的通訊也以介面為基礎,利用 Unity 內建的 AndroidJavaProxy 將 Kotlin 介面註冊到 C# 介面實作。
通訊模組主要著重於我們所謂的活動橋接。
// 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) }
}
}這兩項活動都能存取活動橋接器和其中的公用程式方法。
橋接器包含實作我們定義的 AIDL 介面的服務參照,也提供簡單的方法,讓活動繫結至程序 (抱歉,是服務),並在內部管理其生命週期。
如要開始模擬,可以使用回呼,並將比賽 ID 做為參數傳遞。
接著,橋接器會透過 AIDL 將其傳遞至 Unity 應用程式,C# 會在該應用程式中取得指令並開始設定場景。
public class NativeInterfaceListener : AndroidJavaProxy {
[UsedImplicitly]
public void startMatch(string matchID) {
UnityThreadHelper.InvokeOnMain(() => _internalAPI.StartMatch(matchID));
}
}我們必須等待 Unity 啟動並準備好執行我們傳遞給它的指令。
包裝應用程式的 Unity 活動會在 Unity 本身之前啟用。
如果指令過早傳送,我們會將其儲存在回呼佇列中。
fun onStartMatchClickedInternal() {
if (isServiceBound) {
AIDLBridgeService.startMatch("match_id")
} else {
delayedCallbacksQueue.add(action)
}
}Unity 完成啟動載入和初始化,且我們認為狀態安全後,就會透過 C# 傳達 Unity 已準備就緒。
// 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) }這張圖表更清楚說明瞭程序間通訊的運作方式。
如您所見,使用活動之間的橋接,我們就能處理上述回呼。
我們設定了從 Unity 更新的 StateFlow,用於監控驗證狀態或配對的目前狀態時間戳記等值。
此外,我們也管理憑證加密,並轉送 Unity 的任何錯誤,讓 NBA 能更輕鬆地偵錯,並決定如何在應用程式層級妥善處理這些錯誤。
我的戲分就到這裡
現在,我將把舞台交還給 Josh,讓他進一步說明如何將 Court View Unity 套件整合到 NBA 應用程式中。
我們使用這些 Gradle 依附元件,將 Unity 做為程式庫與 Jetpack Compose 應用程式整合:XR 執行階段用於存取 3D 工作階段、Jetpack SceneCore 用於託管 Unity 活動和切換空間環境,以及 Jetpack Compose for XR 用於在 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")
}接下來,我們來看看如何在 Jetpack Compose 中算繪 Court View。
// 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)
}
}
}在啟動球場檢視畫面之前,我們會先帶你進入全螢幕模式。
必須執行這項操作,才能顯示包含 Unity 的空間面板。
接著,我們透過 AIDL 傳送遊戲資訊,啟動特定遊戲的 Court View。
接著,Unity 本身會啟動。
Android 封存檔會將這項資訊做為意圖傳送至 Unity 活動,我們則會將其包裝在 SpatialActivityPanelController 中。
接著,只要將該控制器傳遞至 SpatialActivityPanel 即可。
這個面板會保留球場檢視畫面,並與主要空間面板共用子空間。
這樣一來,我們就能同時控制這兩個項目,不會發生任何問題。
就是這麼簡單
我會將這份文件傳回給 Luke。
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)
}
}
}