Android XR

在 Jetpack Compose for XR 中使用 3D 引擎,提升沉浸式體驗

喇叭

關於

講座簡介

瞭解如何將 3D 遊戲引擎 (Godot 和 Unity) 與 Jetpack Compose 整合至單一程式碼集,用於行動裝置和 Android XR。我們將探討如何將 3D 場景嵌入空間容器、設定 Kotlin 與引擎之間的通訊,並獨家介紹 NBA 如何在 Android XR 上打造即時籃球體驗。

摘要

  • OpenXR Spatial Containers 擴充功能可讓 3D 引擎與原生 Android UI 共用空間。即時引擎不會接管天空盒,而是在代管的 3D 體積中,與 Jetpack XR 空間面板一起執行。
  • 開發人員可以將即時引擎直接嵌入 Jetpack Compose 應用程式,做為 AAR 程式庫。這樣一來,原生 Android 就能處理商業邏輯和 UI,而引擎則負責算繪 3D 元件。
  • 嵌入 Godot 時,需要 AAR 依附元件和 AAPT 建構規則,才能保留隱藏的資產。GodotPlugin 子類別會處理與 Kotlin 的雙向通訊。
  • Compose UI 迴圈必須與連續引擎 tick 迴圈保持分離。盲目轉送引擎更新會觸發持續重組,導致 UI 延遲。
  • NBA 應用程式將 Unity 整合為獨立程序中的嵌入式程式庫,並使用 AIDL 進行程序間通訊,避免主應用程式當機。

資源

Gemini Notebook

主題

  • android-xr
  • jetpack-compose
  • spatial-containers
  • openxr
  • godot
  • 團結

轉錄稿

Android XR 簡介

  • 大家好。

  • 我是 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 影格。

  • 他們會進行物理計算、複雜的著色器、骨骼動畫、空間音訊,而且所有這些都具有令人難以置信的效能。

整合 3D 引擎的方法

  • 如果想兼顧兩者,該怎麼做?

  • 過去,開發人員必須實作這兩種方法之一。

  • 選項 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 與 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")

在 Jetpack Compose 中算繪 Godot

  • 資產封裝完成後,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 的合作夥伴之一。

NBA Court View 簡介

  • 謝謝你,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 做為程式庫。

將 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。

結論和資源

  • 謝謝大家

  • 如您所見,NBA 將 Unity 和 Jetpack Compose 開發作業分開,加快 Court View 的開發速度。

  • 如要進一步瞭解如何開始使用空間容器,並在自己的 Android XR 應用程式中使用,請參閱 developer.android.com/develop/xr 的開發人員說明文件。

  • 非常感謝各位今天的參與,我們很期待看到您打造的成果。

程式碼

程式碼片段

「Kotlin 與 Godot 之間的通訊」影片 09:30 處的 Kotlin 指令碼片段
androidResources {
     ignoreAssetsPattern = "!.svn:!.git:!.gitignore:!.ds_store:!*.scc:<dir>_*:!CVS:!*~:!android:!export_presets.cfg"
 }
「Kotlin 與 Godot 之間的通訊」影片 10:27 處的 Kotlin 指令碼片段
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")
「在 Jetpack Compose 中算繪 Godot」影片 10:48 處的 Kotlin 程式碼片段
@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 }
    )
}
「在 Jetpack Compose 中算繪 Godot」影片 10:55 處的 Kotlin 程式碼片段
Subspace {
        SpatialPanel(
            modifier = SubspaceModifier
                .width(960.dp)
                .height(540.dp)
                .offset(z = (-200).dp)
                .movable()
,
    ) {
        GodotView(
            godotHost = godotHost,
            modifier = Modifier.fillMaxSize(),
        )
    }
}
「在 Jetpack Compose 中算繪 Godot」影片 11:05 處的 Kotlin 程式碼片段
@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)
       }
   }

}
「在 Jetpack Compose 中算繪 Godot」影片 11:29 處的 Kotlin 程式碼片段
class MainActivity : ComponentActivity(), GodotHost {
    override fun getHostPlugins(engine: Godot): Set<GodotPlugin> =
        setOf(SoccerGodotPlugin(engine))
}
「在 Jetpack Compose 中算繪 Godot」影片 11:52 處的 Kotlin 程式碼片段
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),
    )

}
「在 Jetpack Compose 中算繪 Godot」影片 12:01 處的文字片段
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)
「在 Jetpack Compose 中算繪 Godot」影片 12:10 處的 Kotlin 程式碼片段
class SoccerGodotPlugin(godot: Godot) : GodotPlugin(godot) {

    fun sendCommandToGodot(command: String) {
        emitSignal("match_command", command)
    }
}
「在 Jetpack Compose 中算繪 Godot」影片 12:20 處的文字片段
func _on_goal_scored(minute: int, home: int, away: int) -> void:

    if soccer_plugin:
        soccer_plugin.onMatchEvent("GOAL", minute, home, away)
「在 Jetpack Compose 中算繪 Godot」影片 12:30 處的 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)
        }
    }
「在 Jetpack Compose 中算繪 Godot」影片 14:35 處的 Kotlin 程式碼片段
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, ...) }
}
「在 Jetpack Compose 中算繪 Godot」影片 14:57 處的 Kotlin 程式碼片段
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
       )
   }
「在 Jetpack Compose 中算繪 Godot」影片 15:22 處的 XML 程式碼片段
<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="" />
「將 Unity 整合為程式庫」影片中 20:41 處的 Kotlin 指令碼片段
// 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")
}
}
「Integrating Unity as a library」中 22:11 的 Kotlin 程式碼片段
// 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) }
}
}
「Integrating Unity as a library」影片 22:36 處的 C# 程式碼片段
public class NativeInterfaceListener : AndroidJavaProxy {
[UsedImplicitly]
public void startMatch(string matchID) {
   UnityThreadHelper.InvokeOnMain(() => _internalAPI.StartMatch(matchID));
}
}
「Integrating Unity as a library」影片 22:54 處的 Kotlin 程式碼片段
fun onStartMatchClickedInternal() {
   if (isServiceBound) {
       AIDLBridgeService.startMatch("match_id")
   } else {
       delayedCallbacksQueue.add(action)
   }
}
「Integrating Unity as a library」影片 22:57 處的 C# 程式碼片段
// After bootload and initialization

using AndroidJavaClass nativeBridge = new("com.company.UnityActivity");
using AndroidJavaObject instance = nativeBridge.GetStatic<AndroidJavaObject>("instance");
instance.Call("onUnityReady");
「將 Unity 整合為程式庫」中 23:06 處的 Kotlin 程式碼片段
@Suppress("unused") //  Unity implicit call
fun onUnityReady() {
   bridge.activityBridgeServiceInternal?.onUnityReady()
}

delayedCallbacksQueue.forEach { it(activityBridgeService) }
「Integrating Unity as a library」影片 23:50 處的 Kotlin 指令碼片段
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")
}
「將 Unity 整合為程式庫」中的 Kotlin 程式碼片段 (24:10)
// 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)
        }
    }
}