Kỹ sư phụ trách quan hệ nhà phát triển Android XR
Kỹ sư Android chính
Tìm hiểu cách tích hợp công cụ phát triển trò chơi 3D (Godot và Unity) với Jetpack Compose vào một cơ sở mã duy nhất cho thiết bị di động và Android XR. Chúng ta sẽ tìm hiểu cách nhúng cảnh 3D vào vùng chứa không gian, thiết lập hoạt động giao tiếp giữa Kotlin và công cụ, đồng thời tìm hiểu độc quyền về cách NBA xây dựng trải nghiệm bóng rổ trực tiếp trên Android XR.
GodotPlugin sẽ xử lý hoạt động giao tiếp hai chiều với Kotlin.Bản chép lời
Chào các bạn!
Tôi là Luke Hopkins.
Tôi là kỹ sư quan hệ nhà phát triển trong nhóm Android XR.
Trong buổi trò chuyện hôm nay, chúng ta sẽ nói về cách nhúng các công cụ phát triển trò chơi, chẳng hạn như Godot và Unity, vào dự án Android của bạn.
Sau đó, Josh và Rodrigo của NBA và Beyond Sports sẽ cùng tôi thảo luận về cách họ triển khai Unity dưới dạng thư viện vào ứng dụng NBA.
Trong buổi này, chúng ta sẽ tìm hiểu sâu về cách tích hợp Godot vào ứng dụng Jetpack Compose, sau đó Josh và Rodrigo sẽ giới thiệu về ứng dụng NBA của họ.
Android XR là một phần mở rộng của hệ sinh thái Android, mở rộng trên nhiều kiểu dáng để mang đến những trải nghiệm quen thuộc và yêu thích của bạn vào một chiều không gian mới.
Chúng tôi đã giúp bạn dễ dàng đưa các ứng dụng XR và Android hiện có vào không gian mới này, đồng thời tận dụng các công nghệ Android hiện có mà bạn đang sử dụng để xây dựng ứng dụng.
Android XR là một nền tảng hợp nhất được thiết kế cho nhiều loại thiết bị.
Thiết bị Android XR hỗ trợ nhiều loại trải nghiệm.
Chúng tôi có trải nghiệm tăng cường cho âm thanh và kính có màn hình, đồng thời có trải nghiệm sống động cho thiết bị đeo đầu XR và kính thông minh XR có dây.
Hôm nay, chúng ta sẽ thảo luận về cách tạo trải nghiệm sống động cho kính thông minh XR có dây và thiết bị đeo đầu XR.
Bạn có thể tạo trải nghiệm sống động thông qua SDK Jetpack XR hoặc sử dụng OpenXR với một công cụ theo thời gian thực.
Chúng tôi hỗ trợ Unity, Godot và Unreal để bạn có thể xây dựng trải nghiệm sống động cho Android bất kể bạn dùng công cụ nào.
Trong bài nói chuyện này, tôi sẽ giải thích cách bạn có thể tận dụng API SceneCore của Jetpack, cùng với Godot và một tiện ích OpenXR mới, để thực hiện việc này.
Tại đây, bạn có thể thấy Godot chạy song song với các bảng điều khiển không gian của Jetpack cùng một lúc.
Hãy lưu ý cách Godot được kết xuất dưới dạng nội dung không gian, cung cấp một khối 3D cùng với giao diện người dùng Compose 2D trong một vùng chứa không gian.
Phương pháp này cho phép nhà phát triển chạy các mô phỏng vật lý, chương trình đổ bóng tuỳ chỉnh bằng công cụ thời gian thực mà họ chọn, đồng thời áp dụng các phương pháp phát triển Android gốc cho phần còn lại của ứng dụng.
Với Android XR và XREAL AURA, bạn cũng có thể tạo ra những trải nghiệm như thế này.
Trong buổi trò chuyện này, chúng tôi sẽ hướng dẫn bạn cách xây dựng trải nghiệm này bằng Godot và Android Studio, sau đó bạn sẽ được nghe các đối tác của chúng tôi tại NBA và Beyond Sports chia sẻ về cách họ xây dựng trải nghiệm này bằng Unity dưới dạng thư viện.
Trước đây, việc xây dựng trải nghiệm XR bằng OpenXR đồng nghĩa với việc xây dựng một trải nghiệm kết xuất từng pixel.
Điều này có nghĩa là nếu nhà phát triển cần hiển thị hoạt động thứ hai, thì họ không thể hiển thị cả hai hoạt động cùng một lúc.
Để giải quyết vấn đề này, Khronos, nhóm làm việc OpenXR, phối hợp với một số công ty hàng đầu trong ngành, đã phát hành tiện ích OpenXR Spatial Containers.
Hãy coi vùng chứa không gian như OpenXR trong một hộp.
Thay vì sở hữu toàn bộ hộp bầu trời, công cụ của bạn sẽ được cấp một không gian 3D do hệ thống quản lý, có thể hoạt động song song với các bảng điều khiển không gian XR của Jetpack, các Vùng chứa không gian khác và các hoạt động Android tiêu chuẩn.
Giả sử bạn muốn sử dụng Android XR bằng cách hiển thị hai bảng điều khiển không gian cùng lúc, trong khi chạy một trò chơi sống động.
Bạn kết xuất mọi thứ trong công cụ và điều này giúp trình chỉnh sửa Godot và vùng chứa không gian chạy cùng lúc trong các trường hợp sử dụng này.
Kể từ cấp độ API không gian 4, Google đã triển khai Vùng chứa không gian vào Android XR.
Vùng chứa không gian hỗ trợ hai chế độ hiển thị quan trọng.
Đầu tiên là chế độ giới hạn.
Điều này cung cấp một không gian 3D xác định với một số vùng tránh được tích hợp sẵn.
Người dùng có thể lấy vùng chứa không gian, xoay, định vị, thu nhỏ vùng chứa đó. Vùng chứa này hỗ trợ phép chiếu chiều sâu âm thanh nổi, mang đến cho bạn cửa sổ kỳ diệu hoặc hiệu ứng bật ra 3D mà bạn có được khi cắt các cạnh của vùng chứa.
Thứ hai là chế độ không giới hạn.
Nếu người dùng quyết định muốn trải nghiệm hoàn toàn sống động, thì ứng dụng của bạn có thể yêu cầu chuyển đổi liền mạch từ chế độ giới hạn sang chế độ Toàn bộ không gian, sau đó bạn cũng có thể quay lại chế độ giới hạn.
Nhờ bổ sung vùng chứa không gian, Android XR mở ra cơ hội cho những ý tưởng mới và mở rộng.
Giả sử bạn muốn đưa giao diện người dùng Android vào trò chơi hoặc có thể bạn muốn tạo một chương trình đổ bóng tuỳ chỉnh để hiển thị nội dung 3D trong ứng dụng.
Trước khi có vùng chứa không gian, bạn không thể chạy đồng thời cả Compose và công cụ phát triển trò chơi.
Và khi xem xét việc phát triển ứng dụng trong các công cụ theo thời gian thực, chúng ta cũng đã sống trong hai mô hình khác nhau một cách hiệu quả.
Đối với nhà phát triển Android gốc, thời gian chạy của ứng dụng được xây dựng dựa trên Jetpack Compose.
Công cụ này được thiết kế để tạo giao diện người dùng nhanh, có tính thích ứng, tiết kiệm pin, bố cục linh động, hỗ trợ tiếp cận hệ thống và nhập văn bản liền mạch.
Nó ở trạng thái rảnh khi không có gì xảy ra và có thể dự đoán được.
Đối với nhà phát triển công cụ phát triển trò chơi, lĩnh vực này là kết xuất theo thời gian thực, nơi các công cụ như Godot và Unity toả sáng.
Các ứng dụng này không chỉ chờ người dùng tương tác.
Các trò chơi này chạy một vòng lặp kết xuất và đánh dấu liên tục.
Vì vậy, tốc độ khung hình có thể là 60, 90, 100 khung hình/giây.
Các công cụ này thực hiện các phép tính vật lý, chương trình đổ bóng phức tạp, ảnh động khung xương, âm thanh không gian và tất cả những việc này đều có hiệu suất đáng kinh ngạc.
Vậy điều gì sẽ xảy ra nếu bạn muốn có cả hai?
Trước đây, nhà phát triển phải triển khai một trong hai phương pháp này.
Lựa chọn A là viết lại toàn bộ ứng dụng của bạn trong một công cụ thực tế như Godot hoặc Unity.
Việc loại bỏ toàn bộ mã nguồn hiện có đồng nghĩa với việc mất đi nhiều giao diện người dùng Compose gốc, triển khai lại văn bản và Điều hướng cơ bản, cũng như viết trình bao bọc xung quanh các API Android như chuyển văn bản sang lời nói.
Lựa chọn B là xây dựng quy trình kết xuất đồ hoạ 3D cấp thấp từ đầu bên trong ứng dụng gốc bằng cách sử dụng API Vulkan và OpenXR thô.
Thay vì cung cấp các tính năng của sản phẩm, về cơ bản, bạn đang xây dựng công cụ độc quyền của riêng mình.
Vì vậy, hôm nay tôi có thể hướng dẫn bạn cách khai thác tối đa Godot dưới dạng thư viện và Unity dưới dạng thư viện.
Và với việc bổ sung vùng chứa không gian, giờ đây bạn có thể tận hưởng cả hai lợi ích.
Chúng ta có thể đạt được điều này bằng cách nhúng trực tiếp một công cụ theo thời gian thực vào một thư viện bên trong ứng dụng Jetpack.
Bạn giữ lại cấu trúc ứng dụng Android chính thức, logic nghiệp vụ và khả năng kết xuất liền mạch nội dung không gian 3D bên trong ứng dụng.
Điều này cũng có nghĩa là bạn có thể có một tệp APK cho tất cả kiểu dáng thiết bị.
Nếu không chỉ muốn hiển thị nội dung 3D, bạn có thể chọn không tải thư viện vào Hoạt động hoặc fragment và giữ cho toàn bộ mã nguồn của bạn rõ ràng, đồng thời cho phép bạn hỗ trợ nhiều Thiết bị.
Triết lý này rất đơn giản: tách rời ứng dụng và biến công cụ theo thời gian thực thành một thư viện.
Thay vì chọn giữa ứng dụng Android hoặc công cụ, bạn coi công cụ này là một thư viện phần mềm nhúng, còn được gọi là phần phụ thuộc AAR.
Bạn có thể lấy trực tiếp thư viện này vào bản dựng Gradle, giống như mọi thư viện khác.
Mã Android gốc của bạn vẫn là bộ não tuyệt đối của ứng dụng lưu trữ các thành phần 3D.
Jetpack Compose XR xử lý những việc mà công cụ này làm tốt nhất, đó là kết xuất giao diện người dùng linh hoạt, dễ tiếp cận và quản lý trạng thái ứng dụng.
Khi cần có một phần tử 3D, ứng dụng sẽ lưu trữ một chế độ xem kết xuất được nhúng do chính công cụ phát triển trò chơi kết xuất.
Hôm nay, chúng ta sẽ tìm hiểu kỹ về cách hoạt động của tính năng này trong Godot.
Và tôi sẽ bắt đầu bằng cơ chế nhúng và thiết lập giao tiếp hai chiều giữa Kotlin và GDScript.
Sau đó, chúng ta sẽ tìm hiểu về tiện ích OpenXR mới, Spatial Container, giúp khai phá thế hệ mới của tính năng đa nhiệm và bố cục không gian.
Hãy xem nhanh bản minh hoạ mà tôi đã trình bày trước đó.
Tôi đã tạo trò chơi này bằng Godot và Android Studio.
Đoạn video này cho thấy Jetpack Compose cho XR kết xuất các bảng điều khiển không gian giao diện người dùng 2D gốc cho bảng điểm, thông báo và khoảnh khắc nổi bật.
Ngoài ra, Godot còn kết xuất hình ảnh mô phỏng một trận bóng đá.
Tại đây, bạn có thể xem thông tin chi tiết về cách chúng tôi giao tiếp từ Kotlin đến GDScript.
Để làm được việc này, Godot cung cấp một cấu trúc trình bổ trợ.
Bạn viết một lớp Kotlin gọn nhẹ mở rộng GodotPlugin.
Lớp này đóng vai trò là giao diện giữa JVM và thời gian chạy C++ của Godot.
Do đó, các hoạt động tương tác giữa ứng dụng của bạn và Godot rất đơn giản.
Cấu trúc của Godot hỗ trợ giao tiếp hai chiều giữa Godot và Kotlin.
Khi một sự kiện trong trò chơi xảy ra trong Godot, chẳng hạn như bàn thắng hoặc lỗi, Godot có thể trực tiếp gọi các hàm Kotlin và cập nhật giao diện người dùng Compose theo thời gian thực.
Sau đó, khi người dùng tương tác với trình phát nội dung nghe nhìn, chẳng hạn như tạm dừng hoặc chọn các khoảnh khắc nổi bật khác, Kotlin sẽ phát tín hiệu trở lại Godot và các thao tác tương ứng của công cụ phát triển trò chơi sẽ diễn ra.
Sau khi trình bổ trợ Godot được tích hợp, hiệu suất và việc quản lý sẽ được thực hiện theo các dòng miền.
Áp dụng các phương pháp hay nhất theo tiêu chuẩn cho ứng dụng Android và các phương pháp hay nhất theo tiêu chuẩn cho dự án Godot.
Bây giờ, chúng ta sẽ xem xét cấu trúc.
Hãy cùng xem cách chúng ta kết nối tính năng này vào Godot trong một ứng dụng Android.
Môi trường thời gian chạy của công cụ Godot được phân phối qua Maven dưới dạng tệp AAR.
Việc tích hợp vào bản dựng chỉ cần tuân theo các phương pháp tiêu chuẩn của Android.
Có một bước quan trọng mà bạn cần lưu ý ngay, đó là điều mà tôi gọi là bẫy thành phần.
androidResources {
ignoreAssetsPattern = "!.svn:!.git:!.gitignore:!.ds_store:!*.scc:<dir>_*:!CVS:!*~:!android:!export_presets.cfg"
}Trong dự án Godot, các cảnh và tệp cấu hình dự án sẽ nằm cùng nhau.
Khi nhúng Godot, dự án Godot sẽ được thêm trực tiếp vào thư mục tài sản.
Điều này có nghĩa là tất cả lưới 3D đã nhập, kết cấu nén và kết cấu đã biên dịch đều được lưu trữ dưới dạng cấu phần phần mềm thời gian chạy bên trong một tệp ẩn có tên là .godot.
Theo mặc định, Android sẽ loại bỏ thư mục này khi tạo bản dựng, vì vậy, Android sẽ bỏ qua mọi thư mục có dấu chấm.
Vì vậy, nếu bạn đồng bộ hoá và tạo ứng dụng theo cách thông thường, APK sẽ được tạo nhưng sẽ gặp sự cố và tải vì những nội dung đó chưa được biên dịch vào APK.
Vì vậy, để khắc phục vấn đề này, bạn chỉ cần ghi đè rõ ràng các tuỳ chọn AAPT bên trong build.gradle và cho phép các dấu chấm ở đầu xảy ra từ Godot.
Sau khi thực hiện, bạn có thể chạy cảnh Godot một lần trong trình chỉnh sửa Godot và từ đó, bạn có thể xây dựng mọi thứ khác trong Android Studio.
Để hỗ trợ vùng chứa không gian và OpenXR mới nhất, bạn nên sử dụng Godot 4.8 trở lên, tiện ích của nhà cung cấp Godot 5.2 và trình tải 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")Sau khi đóng gói các thành phần, Godot và Compose sẽ kết xuất như thế nào?
Thư viện Godot cung cấp một phương thức ban đầu có tên là 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 }
)
}Thao tác này sẽ trả về một SurfaceView mà Godot vừa kết xuất.
Vì vậy, bạn gói thành phần này trong một hàm composable và giờ đây, Godot có thể được định kích thước, khoảng đệm và đặt ở bất kỳ vị trí nào trong bảng điều khiển thực thể.
Subspace {
SpatialPanel(
modifier = SubspaceModifier
.width(960.dp)
.height(540.dp)
.offset(z = (-200).dp)
.movable()
,
) {
GodotView(
godotHost = godotHost,
modifier = Modifier.fillMaxSize(),
)
}
}Bản thân công cụ này không thể quản lý vòng đời.
@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)
}
}
}Vì vậy, hoạt động của bạn cần triển khai giao diện GodotHost.
Việc này sẽ đảm bảo rằng mọi vòng đời đang diễn ra trong hoạt động của bạn, khi tạm dừng, khi tiếp tục, đều sẽ được gửi đến chính công cụ Godot và giờ đây, công cụ này đã nhận biết được vòng đời.
Tiếp theo, chúng ta cần thêm ứng dụng gốc và công cụ để giao tiếp liền mạch.
Vì vậy, để làm việc này, chúng ta có thể sử dụng giao diện GodotHost.
class MainActivity : ComponentActivity(), GodotHost {
override fun getHostPlugins(engine: Godot): Set<GodotPlugin> =
setOf(SoccerGodotPlugin(engine))
}Chúng ta có thể xác định trình bổ trợ Godot và tại đây, chúng ta có thể đảm bảo rằng khi Godot khởi động, trình bổ trợ sẽ được đăng ký từ phía Compose.
Bây giờ, sau khi trình bổ trợ đã được đăng ký, hãy thiết lập Android để giao tiếp bằng cách phát tín hiệu đến Godot.
Trong mã Kotlin, chúng ta sẽ tạo một thực thể của GodotPlugin.
Chúng ta sẽ cho hoạt động của mình đăng ký trình bổ trợ với thư viện Godot.
Sau đó, trong Kotlin, chúng ta sẽ tạo GodotPlugin, đăng ký tên và cho Godot biết tất cả tín hiệu mà chúng ta muốn sử dụng trong gói này.
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),
)
}Tiếp theo, chúng ta có một Tập lệnh Godot, nơi bạn có thể lấy thực thể của trình bổ trợ này.
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)Chúng ta có thể xem những gì đã đăng ký, sau đó chỉ cần kết nối các hàm với tín hiệu đã đăng ký.
Sau đó, khi gọi một hàm như emitSignal, bạn chỉ cần truyền hàm này từ mã Kotlin và hàm này sẽ chuyển thẳng đến Godot.
class SoccerGodotPlugin(godot: Godot) : GodotPlugin(godot) {
fun sendCommandToGodot(command: String) {
emitSignal("match_command", command)
}
}Để Godot giao tiếp với Kotlin, giả sử có một mục tiêu xảy ra, chúng ta có thể gọi hàm Kotlin trực tiếp từ Tập lệnh Godot, chỉ bằng cách sử dụng thực thể trình bổ trợ.
func _on_goal_scored(minute: int, home: int, away: int) -> void:
if soccer_plugin:
soccer_plugin.onMatchEvent("GOAL", minute, home, away)Vì vậy, trước tiên, bạn chú thích hàm Kotlin bằng 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)
}
}Công cụ này xử lý việc sắp xếp JNI theo cách không rõ ràng.
Sau đó, chúng ta có thể gọi hàm trực tiếp từ Godot Script.
Có một mẹo nhanh cần nhớ khi sử dụng hàm này, đó là UsedByGodot yêu cầu so khớp chuỗi chính xác.
Vì vậy, nếu Tập lệnh Godot của bạn gọi onMatchEvent, thì tên Kotlin của bạn phải giống hệt nhau, nên không có kiểu viết lạc đà hoặc chuyển đổi kiểu viết con rắn tự động.
Vì vậy, bạn chỉ cần đảm bảo cả hai phương thức đều giống hệt nhau.
Khi trình bổ trợ đã kết nối, phần quan trọng nhất của bài nói chuyện này là điều mà tôi gọi là mô hình tư duy.
Đây là điểm mà công cụ phát triển trò chơi và Jetpack Compose hoạt động trên hai cấu trúc về cơ bản là đối lập nhau.
Công cụ phát triển trò chơi hoạt động dựa trên một nhịp khung hình liên tục.
Cho dù bạn có thay đổi gì hay không, quy trình đánh dấu của công cụ phát triển trò chơi vẫn diễn ra 60, 90, 100 lần mỗi giây, mang lại kết xuất theo thời gian thực phong phú và mượt mà.
Và các công cụ như Godot sử dụng hiệu quả chu kỳ GPU và CPU.
Mặt khác, Jetpack Compose xử lý mọi thứ theo cách phản ứng và dựa trên trạng thái.
Và khi ứng dụng không thay đổi, Compose sẽ tiếp tục, không tiêu thụ CPU.
Nếu bạn muốn kết nối các thành phần này để chạy hai môi trường thời gian chạy mà không có chiến lược rõ ràng, thì các triết lý này có xu hướng xung đột.
Vậy hãy xem cách chúng ta giải quyết vấn đề này bằng cách mà tôi gọi là rò rỉ đánh dấu.
Đây là nơi chúng ta có trải nghiệm 3D, chẳng hạn như đồng hồ tính giờ trên bảng điểm.
Trong tập lệnh của công cụ, có một biến hẹn giờ ti- liên tục thay đổi.
Và nếu bạn chuyển tiếp biến này sang Compose một cách mù quáng, giả sử 90 lần mỗi giây, bạn sẽ vào vòng lặp kết hợp lại này 90 lần mỗi giây.
Điều này có nghĩa là Compose sẽ đánh giá lại các thành phần kết hợp, đo lường lại bố cục, vẽ lại cây giao diện người dùng 90 lần mỗi giây.
Và bạn sẽ thấy hiện tượng rớt khung hình, giao diện người dùng bị giật và thiết bị sẽ nhanh chóng bị giảm hiệu suất do quá nhiệt.
Vì vậy, quy tắc vàng là không bao giờ để vòng lặp công cụ liên tục điều khiển vòng lặp giao diện người dùng phản ứng.
Trong ví dụ này, thay vì để Godot cập nhật đồng hồ tính giờ trên bảng điểm liên tục, tôi chỉ cần đảm bảo Android lấy dữ liệu này mỗi giây một lần khi cần.
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, ...) }
}Cuối cùng, hãy luôn nhớ ranh giới của luồng.
Vòng lặp công cụ phát triển trò chơi Godot liên tục thực thi trên luồng kết xuất riêng.
Giao diện người dùng, giao diện người dùng Android phải thực thi trên luồng chính của Android.
Vì vậy, hãy luôn đảm bảo trình bổ trợ Kotlin của bạn gửi các sự kiện bằng Dispatchers.Main hoặc các dữ liệu gốc ở trạng thái an toàn trước khi cập nhật trạng thái mô hình khung hiển thị.
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
)
}Bằng cách giữ cho công việc tần suất cao trong công cụ và ngữ nghĩa riêng biệt trong Compose, bạn có thể có được khung nhìn 3D mượt mà mà không ảnh hưởng đến thời lượng pin, khả năng hỗ trợ tiếp cận hoặc hiệu suất cho ứng dụng gốc của mình.
Bây giờ, hãy xem cách thêm vùng chứa không gian vào dự án Godot.
Trước tiên, hãy bắt đầu bằng cách thiết lập ứng dụng ở chế độ Toàn bộ không gian, bật vùng chứa không gian bằng gợi ý giới hạn, sau đó đặt thư viện trình tải XR trong tệp kê khai Android.
<uses-native-library
android:name="libopenxr.google.so"
android:required="false" />
<!-- 1. Launch Activity in Full-Space Managed XR mode -->
<property
android:name="android.window.PROPERTY_XR_ACTIVITY_START_MODE"
android:value="XR_ACTIVITY_START_MODE_FULL_SPACE_MANAGED" />
<!-- 2. OpenXR Spatial Container bounds mode hints -->
<property
android:name="org.khronos.openxr.PROPERTY_ACTIVITY_HINT_XR_SPATIAL_CONTAINER_USED_BOUNDS_MODES"
android:value="" />Godot có chế độ cài đặt dự án XR riêng, nơi tôi có thể bật OpenXR, chương trình đổ bóng XR và vùng chứa không gian, chỉ thông qua trình chỉnh sửa của Godot.
Đó là tất cả những gì bạn cần làm để bật Spatial Containers trong Godot.
Giờ đây, khi bạn đã có Godot hoạt động với Android XR, vùng chứa không gian và tất cả những nội dung đã phát triển này, các phương pháp hay nhất để sử dụng vùng chứa không gian, cùng với bảng điều khiển không gian và JXR, bạn nên xem thêm thông tin trên trang web dành cho nhà phát triển.
Chúng ta vừa xem qua một ví dụ về cách sử dụng Vùng chứa không gian trong Godot Engine và Android XR. Tôi chắc chắn rằng nhiều bạn muốn nghe ý kiến của một nhà phát triển đang tích cực làm việc với Vùng chứa không gian cho trải nghiệm Android XR của họ.
Tiếp theo, tôi muốn mời Josh và Rodrigo của NBA, một trong những đối tác đầu tiên sử dụng vùng chứa không gian trong ứng dụng của họ.
Cảm ơn Luke.
Chào mọi người.
Tôi là Josh, nhà phát triển Android tại NBA.
Hôm nay, tôi có mặt cùng đồng nghiệp của mình, Rodrigo Sanchez, một nhà phát triển phần mềm Unity tại Beyond Sports.
Hôm nay, chúng tôi muốn giới thiệu cách chúng tôi sử dụng Unity làm thư viện để tạo Court View cho ứng dụng NBA.
Tại NBA, chúng tôi luôn nỗ lực mang đến những trải nghiệm mới cho người hâm mộ.
Vì vậy, chúng tôi rất vui mừng được hợp tác với Google và Beyond Sports để ra mắt tính năng Court View.
Chế độ xem sân đấu cung cấp hình ảnh tái hiện 3D của các trận đấu NBA, giúp người đăng ký gói League Pass xem trận đấu từ những góc nhìn mới mà video phát sóng thông thường không thể mang lại.
Điều chỉnh tỷ lệ và xoay lại sân đấu, đặt sân đấu ở bất cứ đâu bạn muốn trong không gian của mình.
Di chuyển xung quanh và xem trận đấu từ những góc nhìn độc đáo.
Trong suốt thời gian đó, bạn có thể sử dụng ứng dụng NBA như bình thường, từ việc xem số liệu thống kê đồng bộ hoá đến xem chương trình phát sóng trực tiếp.
Làm cách nào để thiết lập tất cả những điều này?
Court View được kết xuất trong Unity và được ứng dụng Android của chúng tôi sử dụng thông qua Unity as a Library.
Sau đó, chúng tôi tạo một tệp Android Archive duy nhất, được lưu trữ trên Maven Central, để ứng dụng NBA có thể lấy về dưới dạng một phần phụ thuộc duy nhất.
Điều này cho phép chúng tôi phân chia công việc giữa phía Android và phía Unity.
Vì vậy, mỗi người trong chúng ta có thể tập trung vào những gì mình biết rõ nhất và làm việc song song để đẩy nhanh quá trình phát triển tổng thể.
Ví dụ: trong khi nhóm của chúng tôi làm việc trên không gian toàn màn hình, trải nghiệm trong Compose, thì nhóm Unity có thể tập trung vào việc điều chỉnh tỷ lệ và bố cục của sân 3D.
Bây giờ, tôi sẽ nhường lời cho Rodrigo để chia sẻ thêm về dự án Unity và cách họ làm việc với Unity dưới dạng thư viện.
Cảm ơn Josh.
Beyond Sports đã hợp tác với NBA trong vài năm để trình bày hình ảnh trực quan 3D bằng dữ liệu thực tế từ các trận đấu.
Với dự án này, chúng tôi đã điều chỉnh mô phỏng 3D để tương thích với Android XR.
Mô phỏng của chúng tôi được xây dựng trên công cụ Unity bằng C#.
Chúng tôi xuất mô phỏng sang Android bằng Unity dưới dạng Thư viện và xây dựng một lớp giao tiếp bên cạnh bằng Kotlin.
Trước khi gộp hai mô-đun này thành một phần phụ thuộc Maven duy nhất để NBA đưa vào ứng dụng của họ.
Trên màn hình, bạn có thể thấy một video ngắn về cùng một mô phỏng mà Josh đã trình chiếu trước đó, chỉ chạy trên trình chỉnh sửa.
Để đạt được điều này, chúng tôi sử dụng gói Android XR Extensions cho Unity.
Hãy để tôi hướng dẫn bạn thực hiện quy trình này.
Vì vậy, trước khi xây dựng trong Unity, hãy đảm bảo rằng bạn đã chọn đúng chế độ cài đặt và trình bổ trợ.
Tải gói Quản lý trình bổ trợ XR của Unity và gói Tiện ích Android XR của Google cho Unity.
Hãy định cấu hình các chế độ cài đặt này trong phần cài đặt XR Plug-in Management (Quản lý trình bổ trợ XR) để sử dụng nhà cung cấp OpenXR Android XR Extensions.
Đối với trường hợp sử dụng của chúng tôi, chúng tôi muốn sử dụng Vùng chứa không gian, vì vậy, chúng tôi đã bật tính năng đó và tắt XR sống động trong phần cài đặt quản lý phiên.
Chúng tôi không sử dụng gói đầu vào XR vì tất cả dữ liệu đầu vào đều được xử lý theo cách gốc, bên ngoài Unity.
Tiếp theo, trong phần cài đặt trình phát, bạn nên đặt dự án của mình sử dụng cấp độ API tối thiểu là 24 trở lên và điểm truy cập ứng dụng phải được đặt thành Game Activity mới hơn.
Lưu ý rằng bạn không được đánh dấu vào mục Hoạt động.
Chúng tôi chỉ quan tâm đến việc xây dựng cho ARM64, vì vậy, chúng tôi chọn kiến trúc mục tiêu đó và trong phần độ phân giải, chúng tôi đã bật tính năng Chạy trong nền để bạn có thể thực hiện đa nhiệm với các ứng dụng khác và tiếp tục chạy mô phỏng 3D ở bên cạnh.
Chúng tôi cũng bật tính năng Hoạt động có thể đổi kích thước vì tính năng này cần thiết để hiển thị các hộp thoại, chẳng hạn như yêu cầu cấp quyền của hệ thống.
Cuối cùng, bạn nên kết xuất ứng dụng Android XR bằng Universal Render Pipeline và Vulkan Graphics API.
Trong thành phần URP, bạn nên tắt HDR và quá trình xử lý hậu kỳ.
Sau khi hoàn tất tất cả các bước đó, bạn đã sẵn sàng tạo bản dựng.
Chuyển sang cửa sổ bản dựng. Nếu đang dùng Unity 6.1 trở lên, bạn có thể chọn hồ sơ bản dựng Android XR để tách riêng các chế độ cài đặt nếu bạn cũng tạo bản dựng cho Android Mobile.
Trước khi tạo, hãy nhớ bật tính năng xuất dự án.
Sau khi Unity hoàn tất quá trình tạo, bạn sẽ có một dự án Android đang hoạt động. Bạn có thể tập hợp dự án này trong Android Studio và chạy trên một thiết bị như mọi tệp APK Unity khác.
Khi đã ở trong Android Studio, bạn sẽ muốn biến bản dựng đã xuất thành một thư viện để một ứng dụng khác sử dụng.
// 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")
}
}Trong dự án này, chúng tôi có 4 mô-đun khác nhau: mô-đun APK chính để thử nghiệm dưới dạng phần giữ chỗ mô phỏng cho ứng dụng bao bọc của NBA, mô-đun Thư viện Unity chứa bản dựng đã xuất, mô-đun nativeCommunications chứa lớp giao tiếp mà chúng tôi xây dựng giữa các hoạt động và mô-đun packageExporter mà chúng tôi dùng để hợp nhất các AAR thành một gói duy nhất để phân phối đến Maven.
Bạn có thể hợp nhất nhiều AAR bằng cách sử dụng Thư viện hợp nhất Android hoặc các phương thức cộng đồng cũ hơn như Fat AAR.
Bạn có thể giữ cho cấu trúc và hoạt động giao tiếp của mình đơn giản bằng cách giữ cho Unity và hoạt động chính chạy trong cùng một quy trình.
Chúng tôi đã chọn không làm như vậy và quyết định tách hoạt động Unity thành quy trình riêng để bảo vệ trong trường hợp phần Unity gặp sự cố, thì ứng dụng NBA sẽ không bị ảnh hưởng và có thể xử lý việc đóng Unity một cách an toàn mà không gặp sự cố.
Quyết định này buộc chúng tôi phải áp dụng một hệ thống giao tiếp liên quy trình mạnh mẽ hơn.
Vì các hoạt động nằm trong các quy trình khác nhau, nên không có bộ nhớ dùng chung giữa các hoạt động đó và nhiều phương thức IPC phổ biến của Android không hoạt động hoặc không đáp ứng yêu cầu.
Chúng tôi sử dụng Ngôn ngữ định nghĩa giao diện Android (AIDL) để giao tiếp giữa hoạt động chính của NBA và hoạt động Unity được nhúng.
Sau đó, hoạt động giao tiếp giữa Android và Unity cũng dựa trên các giao diện, sử dụng AndroidJavaProxy tích hợp sẵn trong Unity để đăng ký giao diện Kotlin cho quá trình triển khai giao diện C#.
Mô-đun giao tiếp của chúng tôi chủ yếu tập trung vào cái mà chúng tôi gọi là cầu nối hoạt động.
// 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) }
}
}Cả hai hoạt động đều có quyền truy cập vào cầu nối hoạt động và các phương thức tiện ích trong đó.
Cầu nối này chứa thông tin tham chiếu đến một dịch vụ triển khai các giao diện AIDL mà chúng ta đã xác định, đồng thời cung cấp một cách dễ dàng để các hoạt động liên kết với quy trình, xin lỗi, với dịch vụ và quản lý vòng đời của dịch vụ đó trong nội bộ.
Bạn có thể bắt đầu mô phỏng bằng một lệnh gọi lại, trong đó chúng ta truyền mã nhận dạng trận đấu dưới dạng tham số.
Sau đó, cầu nối sẽ truyền lệnh này qua AIDL rồi đến ứng dụng Unity, nơi C# nhận lệnh và bắt đầu định cấu hình cảnh.
public class NativeInterfaceListener : AndroidJavaProxy {
[UsedImplicitly]
public void startMatch(string matchID) {
UnityThreadHelper.InvokeOnMain(() => _internalAPI.StartMatch(matchID));
}
}Chúng ta cần đợi Unity hoạt động và sẵn sàng nhấn các lệnh mà chúng ta truyền vào.
Hoạt động Unity bao bọc ứng dụng sẽ hoạt động trước khi Unity hoạt động.
Trong trường hợp các lệnh được gửi quá sớm, chúng tôi sẽ lưu trữ các lệnh đó trong hàng đợi gọi lại.
fun onStartMatchClickedInternal() {
if (isServiceBound) {
AIDLBridgeService.startMatch("match_id")
} else {
delayedCallbacksQueue.add(action)
}
}Sau khi Unity hoàn tất quá trình tải khởi động và khởi chạy, đồng thời chúng ta đang ở trạng thái mà chúng ta cho là an toàn, chúng ta sẽ thông báo từ C# rằng Unity đã sẵn sàng.
// After bootload and initialization
using AndroidJavaClass nativeBridge = new("com.company.UnityActivity");
using AndroidJavaObject instance = nativeBridge.GetStatic<AndroidJavaObject>("instance");
instance.Call("onUnityReady");Sau đó, chúng ta lặp lại hàng đợi và chuyển tiếp tất cả lệnh đã lưu trữ.
@Suppress("unused") // Unity implicit call
fun onUnityReady() {
bridge.activityBridgeServiceInternal?.onUnityReady()
}
delayedCallbacksQueue.forEach { it(activityBridgeService) }Sơ đồ này minh hoạ rõ hơn cách hoạt động của tính năng giao tiếp giữa các quy trình.
Như bạn có thể thấy, bằng cách sử dụng cầu nối giữa các hoạt động, chúng ta có thể xử lý các lệnh gọi lại mà chúng ta đã đề cập.
Chúng tôi thiết lập StateFlows được cập nhật từ Unity để theo dõi các giá trị như trạng thái xác thực hoặc dấu thời gian trạng thái hiện tại của trận đấu.
Ngoài ra, chúng tôi cũng quản lý việc mã hoá thông tin đăng nhập và chuyển tiếp mọi lỗi từ Unity để NBA có thể gỡ lỗi dễ dàng hơn và quyết định cách xử lý tốt nhất ở cấp ứng dụng.
Tôi chỉ có vậy thôi.
Bây giờ, tôi xin nhường lại sân khấu cho Josh để anh ấy có thể chia sẻ thêm về cách họ tích hợp gói Court View Unity vào ứng dụng NBA.
Chúng tôi sử dụng các phần phụ thuộc Gradle này để tích hợp Unity dưới dạng thư viện với ứng dụng Jetpack Compose: XR Runtime để truy cập vào phiên 3D, Jetpack SceneCore để lưu trữ hoạt động Unity và bật/tắt môi trường không gian, và Jetpack Compose cho XR để truy cập vào bảng điều khiển không gian trong mã 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")
}Tiếp theo, hãy xem cách chúng ta kết xuất Chế độ xem sân trong 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)
}
}
}Trước khi phát hành Chế độ xem sân, chúng tôi sẽ đưa bạn vào chế độ toàn bộ không gian.
Bạn cần làm việc này để hiển thị bảng điều khiển không gian chứa Unity.
Tiếp theo, chúng ta sẽ gửi thông tin trò chơi qua AIDL để chạy Chế độ xem sân cho một trò chơi cụ thể.
Sau đó, Unity sẽ khởi động.
Tệp lưu trữ Android của chúng tôi gửi thông tin này dưới dạng ý định đến một hoạt động Unity và chúng tôi gói thông tin này trong SpatialActivityPanelController.
Từ đó, chúng ta chỉ cần truyền bộ điều khiển đó đến SpatialActivityPanel.
Bảng điều khiển này chứa Chế độ xem sân và được chia sẻ trong không gian con với bảng điều khiển không gian chính.
Nhờ đó, chúng ta có thể điều khiển cả hai cùng một lúc mà không gặp vấn đề gì.
Chỉ vậy thôi.
Tôi sẽ chuyển lại cho Luke.
Cảm ơn bạn rất nhiều.
Như bạn có thể thấy, NBA đã có thể đẩy nhanh quá trình phát triển Court View bằng cách tách riêng quá trình phát triển Unity và Jetpack Compose.
Để tìm hiểu thêm về cách bắt đầu sử dụng vùng chứa không gian và cách dùng vùng chứa này trong các ứng dụng Android XR của riêng bạn, hãy xem tài liệu dành cho nhà phát triển tại developer.android.com/develop/xr.
Cảm ơn các bạn đã tham gia cùng chúng tôi hôm nay. Chúng tôi rất mong được thấy những sản phẩm các bạn sẽ xây dựng.
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)
}
}
}