مهندس کارکنان Android
با نحوه ادغام موتورهای بازی سهبعدی (Godot و Unity) با Jetpack Compose در یک پایگاه کد واحد برای تلفن همراه و Android XR آشنا شوید. جاسازی صحنههای سهبعدی در محفظههای فضایی و راهاندازی ارتباط Kotlin-به-موتور را بررسی خواهیم کرد — با نگاهی انحصاری به نحوه ساخت تجربه بسکتبال زنده NBA در Android XR.
GodotPlugin ارتباط دوطرفه با Kotlin را مدیریت میکند.آوانویسی
سلام به همه.
نام من لوک هاپکینز است.
من مهندس روابط توسعهدهندگان در تیم Android XR هستم.
در گفتگوی امروز، درباره نحوه جاسازی موتورهای بازی، مانند Godot و Unity، در پروژه Android شما صحبت خواهیم کرد.
بعداً، جاش و رودریگو از NBA و Beyond Sports به من ملحق خواهند شد تا درباره نحوه پیادهسازی Unity بهعنوان کتابخانه در برنامه NBA خود صحبت کنند.
در این جلسه، میخواهیم به نحوه ادغام Godot در برنامه Jetpack Compose شما بپردازیم و سپس جاش و رودریگو برنامه NBA خود را بررسی خواهند کرد.
Android XR افزونهای از بومسازگان Android است که با گسترش طیف وسیعی از ضرایب شکل، تجربههایی را که میشناسید و دوست دارید به بُعد جدیدی میبرد.
ما انتقال برنامههای XR و Android موجود شما به این فضای جدید را آسان کردهایم و از فناوریهای Android موجود که امروزه با آنها کار میکنید بهره میبریم.
Android XR پلاتفرمی یکپارچه است که برای طیف گستردهای از دستگاهها طراحی شده است.
دستگاههای Android XR انواع مختلفی از تجربهها را ارائه میدهند.
ما تجربههای افزوده برای صدا و «عینک نمایشگردار» داریم و سپس تجربههای فراگیر برای هدستهای واقعیت گسترده و عینکهای واقعیت گسترده سیمی داریم.
امروز درباره ساختن تجربههای فراگیر برای عینکهای واقعیت گسترده سیمی و هدستهای واقعیت گسترده صحبت خواهیم کرد.
ایجاد تجربههای فراگیر را میتوان ازطریق «کیت توسعه نرمافزار Jetpack XR» یا بااستفاده از OpenXR با موتور همزمان انجام داد.
ما از Unity، Godot، و Unreal پشتیبانی میکنیم تا بتوانید تجربههای فراگیر خود را برای Android بسازید، صرفنظر از اینکه از چه ابزاری استفاده میکنید.
در این گفتگو، توضیح خواهم داد که چگونه میتوانید از میانای برنامهسازی کاربردی SceneCore Jetpack، درکنار Godot و افزونه جدید OpenXR، برای ممکن کردن این امر استفاده کنید.
در اینجا میتوانید Godot را ببینید که در کنار پانلهای فضایی Jetpack بهطور همزمان اجرا میشود.
توجه کنید که Godot چگونه بهعنوان محتوای فضایی ارائه میشود و حجم سهبعدی را درکنار رابط کاربری Compose دوبعدی در محفظه فضایی ارائه میدهد.
این رویکرد به توسعهدهندگان امکان میدهد شبیهسازیهای فیزیک و سایهزنهای سفارشی را بااستفاده از موتور همزمان انتخابیشان اجرا کنند و درعینحال، روشهای توسعه بومی Android را برای بقیه برنامه شما اعمال کنند.
با Android XR و XREAL AURA، شما هم میتوانید چنین تجربههایی ایجاد کنید.
و در این گفتگو، به شما آموزش میدهیم که چگونه این تجربه را بااستفاده از Godot و Android Studio بسازید، و سپس از شرکای ما در NBA و Beyond Sports خواهید شنید که چگونه آن را بااستفاده از Unity بهعنوان کتابخانه ساختهاند.
در گذشته، ساختن تجربه واقعیت گسترده با OpenXR بهمعنای ساختن تجربهای بود که تکتک پیکسلها را پرداز میکرد.
این یعنی اگر توسعهدهندهای نیاز به نمایش فعالیت دوم داشته باشد، نمیتواند هر دو را همزمان داشته باشد.
برای حل این مشکل، Khronos، گروه کاری OpenXR، با همکاری چند رهبر صنعت، افزونه OpenXR Spatial Containers را منتشر کردهاند.
«محفظههای فضایی» را بهعنوان OpenXR در یک جعبه درنظر بگیرید.
بهجای اینکه موتور شما مالک کل جعبه آسمان باشد، یک حجم سهبعدی تحت مدیریت سیستم به آن اعطا میشود که میتواند در کنار پانلهای فضایی XR Jetpack، سایر «محفظههای فضایی»، و فعالیتهای استاندارد Android قرار گیرد.
فرض کنید میخواهید با نمایش همزمان دو پانل فضایی، درحالیکه یک بازی غوطهور اجرا میشود، از Android XR استفاده کنید.
همه چیز را در موتور رندر میکنید و این امکان را فراهم میکند که در این موارد استفاده، ویرایشگر Godot و محفظه فضایی را بهطور همزمان اجرا کنید.
از سطح ۴ Spatial API، Google «محفظههای فضایی» را در Android XR پیادهسازی کرده است.
«محفظههای فضایی» از دو حالت نمایش مهم پشتیبانی میکنند.
اولی حالت محدود است.
این کار یک حجم سهبعدی تعریفشده با چندین اجتناب داخلی ارائه میدهد.
کاربر میتواند محفظه فضایی شما را بگیرد، آن را بچرخاند، موقعیت آن را تغییر دهد، آن را کوچک کند، و این محفظه از نمایش عمق استریو پشتیبانی میکند، که به شما آن پنجره جادویی یا جلوه سهبعدی برجسته را میدهد که درحین برش لبههای محفظه نیز دارید.
دومی حالت نامحدود است.
اگر کاربر تصمیم بگیرد که میخواهد غوطهوری کامل داشته باشد، برنامهتان میتواند انتقال بیوقفهای را از حالت محدودشده به حالت فضای کامل درخواست کند و سپس میتوانید به حالت محدودشده برگردید.
با افزودن «محفظههای فضایی»، Android XR در را بهروی ایدههای جدید و گسترده باز میکند.
فرض کنید میخواهید میانای کاربر Android را به بازیتان اضافه کنید، یا شاید بخواهید سایهزن سفارشی برای نمایش محتوای سهبعدی در برنامهتان ایجاد کنید.
قبلاز معرفی «محفظههای فضایی»، امکان اجرای همزمان Compose و موتور بازی وجود نداشت.
و وقتی به توسعه برنامه در موتورهای همزمان نگاه میکنیم، ما نیز بهطور مؤثری در دو الگوی متفاوت زندگی کردهایم.
برای توسعهدهندگان Android بومی، زمان اجرای برنامه شما براساس Jetpack Compose ساخته شده است.
این چارچوب برای رابط کاربری سریع و واکنشی، کارایی باتری، چیدمانهای پویا، دسترسپذیری سیستم، و ورودی نوشتاری یکپارچه طراحی شده است.
وقتی اتفاقی نمیافتد، غیرفعال است و قابلپیشبینی است.
برای توسعهدهندگان موتور بازی، دامنه رندر کردن همزمان است که ابزارهایی مانند Godot و Unity در آن میدرخشند.
آنها فقط منتظر تعامل کاربر نمیمانند.
آنها یک حلقه تیک و رندر مداوم را اجرا میکنند.
بنابراین این میتواند با نرخ فریم ۶۰، ۹۰، ۱۰۰ فریم در ثانیه باشد.
آنها محاسبات فیزیک، سایهزنهای پیچیده، پویانماییهای اسکلتی، صدای فضایی، و همه اینها را با عملکردی باورنکردنی انجام میدهند.
پس اگر بخواهید از مزایای هر دو بهرهمند شوید چه اتفاقی میافتد؟
قبلاً توسعهدهندگان باید یکی از این دو رویکرد را اجرا میکردند.
گزینه الف این است که کل برنامهتان را در یک موتور واقعی مثل Godot یا Unity بازنویسی کنید.
دور انداختن پایگاه کد موجود بهمعنای ازدست دادن بخش زیادی از Compose UI بومی، پیادهسازی مجدد نوشتار و پیمایش پایه، و نوشتن پوشاننده برای میاناهای برنامهسازی کاربردی Android مانند نوشتار به گفتار است.
گزینه ب ساختن خط لوله رندر گرافیک سهبعدی سطح پایین از ابتدا در برنامه بومی شما بااستفاده از Vulkan خام و میاناهای برنامهسازی کاربردی OpenXR است.
بهجای ارائه ویژگیهای محصول، درواقع موتور اختصاصی خودتان را میسازید.
بنابراین امروز میتوانم به شما نشان دهم که چگونه میتوانید از Godot بهعنوان کتابخانه و Unity بهعنوان کتابخانه نهایت استفاده را ببرید.
و با این کار، یعنی افزودن محفظههای فضایی، اکنون میتوانید بهترینهای هر دو دنیا را داشته باشید.
میتوانیم این کار را با جاسازی کردن یک موتور همزمان مستقیماً در کتابخانهای در برنامه Jetpack شما انجام دهیم.
ساختار برنامه Android تولید، منطق کسبوکار، و رندر کردن یکپارچه محتوای فضایی سهبعدی را در برنامهتان حفظ میکنید.
این یعنی میتوانید یک APK برای همه ضرایب شکل داشته باشید.
در مواردی که نمیخواهید فقط محتوای سهبعدی را نشان دهید، میتوانید کتابخانه را در فعالیت یا قطعه بار نکنید و پایگاه کدتان را تمیز نگه دارید، و به شما امکان میدهد از دستگاههای زیادی پشتیبانی کنید.
فلسفه آن ساده است، برنامه را جدا کنید و موتور همزمان را به کتابخانه تبدیل کنید.
بهجای انتخاب بین برنامه Android یا موتور، با موتور بهعنوان کتابخانه نرمافزار جاسازیشده رفتار میکنید که بهعنوان وابستگی AAR نیز شناخته میشود.
این را مستقیماً به ساخت Gradle خود میکشید، درست مانند هر کتابخانه دیگری.
کد Android بومی شما همچنان مغز مطلق برنامه میزبان مؤلفههای سهبعدی باقی میماند.
Jetpack Compose XR کاری را که در آن بهترین است انجام میدهد: ارائه میانای کاربر روان، بومی، و دسترسپذیر و مدیریت وضعیت برنامه.
و وقتی به عنصر سهبعدی نیاز باشد، برنامه میزبان نمای پرداز جاسازیشدهای میشود که توسط خود موتور بازی پرداز میشود.
امروز میخواهیم دقیقاً نحوه عملکرد این ویژگی را در پسزمینه برای Godot بررسی کنیم.
و با مکانیک جاسازی و برقراری ارتباط دوطرفه بین Kotlin و GDScript شروع خواهم کرد.
و سپس به افزونه جدید OpenXR، «محفظه فضایی»، نگاهی خواهیم انداخت که واقعاً نسل جدیدی از چندوظیفگی و چیدمان فضایی را باز خواهد کرد.
بیایید نگاهی سریع به نسخه نمایشیای که قبلاً نشان دادم بیندازیم.
این را بااستفاده از Godot و «استودیو Android» ساختم.
و این کلیپ Jetpack Compose برای XR را نشان میدهد که پانلهای فضایی واسط کاربر دوبعدی بومی را برای تابلو امتیازات و اعلانها و نکات برجسته ما ارائه میدهد.
And on top of this, Godot is rendering a simulation of a soccer match.
در اینجا میتوانید تفکیک نحوه برقراری ارتباط از Kotlin به GDScript را ببینید.
برای انجام این کار، Godot معماری افزایه ارائه میدهد.
کلاس Kotlin سبکی مینویسید که GodotPlugin را گسترش میدهد.
این کلاس بهعنوان میانایی بین JVM و زمان اجرای C++ در Godot عمل میکند.
بههمین دلیل، تعاملات بین برنامه شما و Godot بسیار بسیار سبک است.
معماری Godot از ارتباط دوطرفه بین Godot و Kotlin پشتیبانی میکند.
وقتی رویدادی در بازی در Godot رخ میدهد، مانند گل یا خطا، Godot میتواند مستقیماً توابع Kotlin را فراخوانی کند و رابط کاربری Compose را بهطور همزمان بهروز کند.
سپس وقتی کاربر با پخشکننده رسانه تعامل برقرار میکند، مثلاً برای توقف موقت یا انتخاب بخشهای جالب مختلف، 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 مستقیماً در پوشه داراییهای شما اضافه میشود.
بنابراین، این یعنی همه مشهای سهبعدی واردشده، بافتهای فشردهشده، و بافتهای کامپایلشده، همگی بهعنوان یک آرتیفکت زمان اجرا در داخل یک فایل پنهان به نام godot .ذخیره میشوند.
بهطور پیشفرض، Android این پوشه را هنگام ساختن حذف میکند، بنابراین هر دایرکتوریای را که نقطه داشته باشد نادیده میگیرد.
بنابراین اگر برنامهتان را بهطور معمول همگامسازی و ایجاد کنید، APK ایجاد میشود، اما ازکار میافتد و بار میشود چون این موارد در APK کامپایل نشدهاند.
بنابراین، برای رفع این مشکل، میتوانید بهطور صریح گزینههای AAPT را در build.gradle لغو کنید و اجازه دهید نقطههای ابتدایی از 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 }
)
}این کار SurfaceView را برمیگرداند که Godot فقط به آن رندر میکند.
بنابراین، این را در یک تابع «ترکیبپذیر» میپیچید و اکنون 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 Script را داریم که در آن میتوانید نمونه این افزونه را دریافت کنید.
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 ارتباط برقرار کند، مثلاً وقتی گلی زده میشود، میتوانیم تابع Kotlin را مستقیماً از Godot Script و فقط بااستفاده از نمونه افزونه فراخوانی کنیم.
func _on_goal_scored(minute: int, home: int, away: int) -> void:
if soccer_plugin:
soccer_plugin.onMatchEvent("GOAL", minute, home, away)بنابراین ابتدا تابع Kotlin را با 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)
}
}موتور، مرتبسازی JNI را در پشت صحنه انجام میدهد.
و سپس میتوانیم تابع را مستقیماً از Godot Script فراخوانی کنیم.
یک نکته سریع وجود دارد که باید هنگام استفاده از این مورد بهخاطر داشته باشید و آن این است که UsedByGodot به تطابق دقیق رشته نیاز دارد.
بنابراین، اگر Godot Script شما onMatchEvent را فراخوانی کند، نام Kotlin شما باید دقیقاً یکسان باشد، بنابراین هیچ تبدیل خودکار نگارش شتری یا نگارش ماری وجود ندارد.
بنابراین، فقط باید مطمئن شوید که هر دو روش دقیقاً یکسان هستند.
با متصل شدن افزونه، مهمترین بخش این صحبت چیزی است که من آن را مدل ذهنی مینامم.
اینجاست که موتورهای بازی و Jetpack Compose روی دو معماری اساساً متضاد عمل میکنند.
موتور بازی براساس تیک قاب پیوسته کار میکند.
چه چیزی را تغییر داده باشید چه نه، فرایند تیک موتور بازی ۶۰، ۹۰، ۱۰۰ بار در ثانیه اتفاق میافتد و رندرینگ همزمان غنی و روانی را ارائه میدهد.
و موتورهایی مانند Godot بهطور مؤثر از چرخههای GPU و CPU استفاده میکنند.
ازسویدیگر، Jetpack Compose موارد را بهصورت واکنشی مدیریت میکند و مبتنی بر حالت است.
و وقتی برنامهای تغییر نمیکند، Compose بهمعنای واقعی کلمه ادامه میدهد و ۰٪ از CPU را مصرف میکند.
اگر بخواهید این دو را برای اجرای دو زمان اجرا بدون استراتژی مشخصی متصل کنید، این فلسفهها معمولاً باهم تداخل پیدا میکنند.
بنابراین، بیایید ببینیم چگونه- بیایید- بیایید ببینیم چگونه این مشکل را با چیزی که من آن را نشت تیک مینامم حل میکنیم.
اینجا جایی است که تجربه سهبعدی داریم، مثلاً زمانسنج تابلو نتایج.
در اسکریپت موتور شما، یک متغیر زمانسنج ti- وجود دارد که بهطور مداوم تغییر میکند.
و اگر این متغیر را بدون بررسی به Compose بازارسال کنید، مثلاً ۹۰ بار در ثانیه، ۹۰ بار در ثانیه وارد این حلقه بازترکیببندی خواهید شد.
بنابراین، این یعنی Compose، Composables شما را مجدداً ارزیابی میکند، چیدمانها را مجدداً اندازهگیری میکند، و درختهای واسط کاربر را ۹۰ بار در ثانیه مجدداً ترسیم میکند.
و شاهد افت فریم خواهید بود، و تأخیر واسط کاربر را خواهید دید، و خیلی سریع به حد آستانه حرارتی خواهید رسید.
بنابراین، قانون طلایی این است که هرگز اجازه ندهید حلقه موتور پیوسته بر حلقه واسط کاربر واکنشی دیکته کند.
در این مثال، بهجای اینکه 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 باید در رشته اصلی 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، میتوانید درگاههای دید سهبعدی بسیار روان داشته باشید بدون اینکه عمر باتری، دسترسپذیری، یا عملکرد برنامه به زبان اصلی دستگاه شما بهخطر بیفتد.
اکنون، بیایید ببینیم چگونه میتوانیم «محفظههای فضایی» را به پروژه Godot شما اضافه کنیم.
ابتدا، بیایید با تنظیم برنامه برای حالت «فضای کامل»، فعال کردن «محفظههای فضایی» با راهنماییهای مرزی آن، و سپس تنظیم کتابخانه بارکننده XR در فایل مانیفست 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 تنظیمات پروژه XR خودش را دارد که در آن میتوانم OpenXR، سایهزنهای XR، و Spatial Containers را فقط ازطریق ویرایشگر آن فعال کنم.
و این تقریباً تمام کاری است که باید برای فعال کردن «محفظههای فضایی» در Godot انجام دهید.
اکنون که Godot را با Android XR، «محفظههای فضایی»، و همه این موارد توسعهیافته راهاندازی کردهاید، برای بهترین شیوههای استفاده از «محفظههای فضایی» بههمراه پانلهای فضایی و JXR، قطعاً توصیه میکنیم اطلاعات بیشتر را در وبسایت توسعهدهنده بررسی کنید.
ما بهتازگی نمونهای از نحوه استفاده از «محفظههای فضایی» در Godot Engine و Android XR را بررسی کردیم و مطمئنم بسیاری از شما مایلید از توسعهدهندهای که بهطور فعال روی «محفظههای فضایی» برای تجربه Android XR خود کار میکند، بشنوید.
بنابراین، در ادامه، میخواهم از جاش و رودریگو از NBA دعوت کنم که یکی از اولین برنامهها-- یکی از اولین شرکایی هستند که از «محفظههای فضایی» در برنامه خود استفاده میکنند.
ممنون، لوک.
سلام به همه.
نام من جاش است و توسعهدهنده نرمافزار Android در NBA هستم.
امروز با همکارم، رودریگو سانچز، توسعهدهنده نرمافزار Unity در Beyond Sports، اینجا هستم.
و امروز میخواهیم به شما نشان دهیم که چگونه از Unity بهعنوان کتابخانه برای ساختن Court View برای برنامه NBA استفاده کردیم.
ما در NBA همیشه بهدنبال ارائه تجربههای جدید به طرفدارانمان هستیم.
بنابراین، با همکاری Google و Beyond Sports، مشتاقیم که «نمای زمین» را معرفی کنیم.
«نمای زمین» بازسازی سهبعدی بازیهای NBA را ارائه میدهد و به مشترکین «گذربلیت لیگ» امکان میدهد بازیها را از دیدگاههای جدیدی تماشا کنند که با ویدیوهای همهفرستی معمولی امکانپذیر نیست.
زمین بازی را مقیاسبندی کنید و دوباره بچرخانید و آن را در هر جایی از فضای خود که دوست دارید قرار دهید.
در اطراف آن حرکت کنید و بازی را از زوایای منحصربهفرد تماشا کنید.
در تمام این مدت، میتوانید از برنامه NBA خود درست مثل حالت عادی استفاده کنید، از مشاهده آمار همگامسازی گرفته تا تماشای پخش زنده.
اینها چطور راهاندازی شدهاند؟
«نمای زمین» در Unity رندر میشود و برنامه Android ما بااستفاده از Unity بهعنوان کتابخانه از آن استفاده میکند.
سپس یک «بایگانی Android» واحد تولید میکنیم که در Maven Central میزبانی میشود و برنامه NBA میتواند آن را بهعنوان یک وابستگی واحد دریافت کند.
این کار به ما امکان داد تا کار را بین بخش Android و بخش Unity تقسیم کنیم.
بنابراین هریک از ما میتواند روی چیزی که بهتر میداند تمرکز کند و بهطور موازی کار کند تا توسعه کلی را سرعت ببخشد.
برای مثال، درحالیکه تیم ما روی تجربه فضای کامل در Compose کار میکرد، تیم Unity میتوانست روی مقیاسبندی و چیدمان زمین سهبعدی تمرکز کند.
اکنون، نوبت رودریگو است تا درباره پروژه Unity و نحوه کار آنها با Unity بهعنوان کتابخانه بیشتر صحبت کند.
متشکرم، جاش.
ما در Beyond Sports چند سالی است که با NBA همکاری میکنیم تا تجسمهای سهبعدی را با دادههای واقعی از مسابقات ارائه دهیم.
با این پروژه، شبیهسازی سهبعدی خود را برای سازگاری با Android XR تطبیق دادیم.
شبیهسازی ما با C# در موتور Unity ساخته شده است.
ما شبیهسازی را با Unity as a Library به Android صادر میکنیم و یک لایه ارتباطی در کنار آن بااستفاده از Kotlin میسازیم.
قبلاز اینکه این دو ماژول را بهعنوان یک وابستگی Maven برای NBA در برنامه خود قرار دهیم.
در صفحه، میتوانید ویدیو کوتاهی از همان شبیهسازی را که جاش قبلاً نشان داد ببینید، که فقط در ویرایشگر اجرا میشود.
برای دستیابی به این هدف، از بسته «افزونههای Android XR» برای Unity استفاده میکنیم.
اجازه دهید فرایند را برایتان توضیح دهم.
بنابراین، قبلاز ساختن در Unity، مطمئن شوید که تنظیمات و افزایههای درست را انتخاب کرده باشید.
بسته «مدیریت افزونه واقعیت گسترده» Unity و بسته «افزونههای Android XR برای Unity» از Google را دریافت کنید.
آنها را در تنظیمات «مدیریت افزونه XR» پیکربندی کنید تا از ارائهدهنده «افزونههای Android XR» OpenXR استفاده کنید.
برای مورد استفاده ما، میخواستیم از «محفظههای فضایی» استفاده کنیم، بنابراین آن ویژگی را فعال کردیم و XR غوطهور را از تنظیمات مدیریت جلسه غیرفعال کردیم.
از هیچ بسته ورودی واقعیت گستردهای استفاده نکردیم، زیرا همه ورودیها بهصورت بومی و خارج از Unity مدیریت میشوند.
سپس، در تنظیمات پخشکننده، باید پروژه خود را طوری تنظیم کنید که از حداقل سطح میانای برنامهسازی کاربردی ۲۴ یا بالاتر استفاده کند و نقطه ورود برنامه باید روی «فعالیت بازی» جدیدتر تنظیم شود.
توجه داشته باشید که «فعالیت» باید لغو انتخاب شود.
ما فقط به ساختن برای ARM64 علاقهمندیم، بنابراین آن معماری هدف را انتخاب میکنیم، و در بخش وضوح، «اجرا در پسزمینه» را فعال میکنیم تا بتوانید با برنامههای دیگر چندوظیفگی انجام دهید و شبیهسازی سهبعدی را در کنار آن اجرا کنید.
همچنین «فعالیت قابلتغییر اندازه» را فعال میکنیم، زیرا برای ارائه گفتگوها، مانند درخواستهای اجازه سیستم، لازم است.
درنهایت، توصیه میشود که برنامههای Android XR بااستفاده از Universal Render Pipeline و Vulkan Graphics API پردازش شوند.
در دارایی URP، باید HDR و پسپردازش غیرفعال باشد.
وقتی همه این مراحل را انجام دادید، آماده ساختن هستید.
به پنجره ساخت بروید و اگر از Unity 6.1 یا بالاتر استفاده میکنید، میتوانید نمایه ساخت Android XR را انتخاب کنید که به شما امکان میدهد تنظیمات را جدا کنید، اگر برای Android Mobile نیز ساخت انجام میدهید.
قبلاز ساختن، حتماً صادر کردن پروژه را فعال کنید.
پساز اینکه Unity ساخت را بهپایان رساند، یک پروژه Android فعال خواهید داشت که میتوان آن را در Android Studio مونتاژ کرد و مانند هر APK دیگر Unity در دستگاه اجرا کرد.
وقتی در 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 استفاده میکنیم.
ادغام کردن چند AAR را میتوان بااستفاده از Android Fused Library یا روشهای قدیمیتر انجمن مثل Fat AAR انجام داد.
با اجرای Unity و فعالیت اصلی در یک فرایند، میتوانید ساختار و ارتباط را ساده نگه دارید.
ما این کار را نکردیم و تصمیم گرفتیم فعالیت Unity را به فرایند خودش جدا کنیم تا بهعنوان یک اقدام احتیاطی، درصورتیکه بخش Unity ازکار بیفتد، برنامه NBA تحت تأثیر قرار نگیرد و بتواند بهطور ایمن و بدون ازکار افتادن خودش، Unity را بهخوبی ببندد.
این تصمیم ما را مجبور میکند تا سیستم ارتباطی بینفرایندی قویتری را اتخاذ کنیم.
ازآنجاییکه فعالیتها در فرایندهای مختلفی هستند، هیچ حافظهای بین آنها همرسانی نمیشود و بسیاری از روشهای رایج IPC در Android کار نمیکنند یا الزامات را برآورده نمیکنند.
ما از «زبان تعریف میانای Android» یا AIDL برای برقراری ارتباط بین فعالیت اصلی NBA و فعالیت Unity جاسازیشده استفاده میکنیم.
سپس، ارتباط بین Android و Unity نیز براساس میاناهای کاربر است، بااستفاده از AndroidJavaProxy داخلی در Unity برای ثبت میانای کاربر 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 را که تعریف کردیم پیادهسازی میکند، و همچنین روشی آسان برای فعالیتها فراهم میکند تا به فرایند، ببخشید، به سرویس متصل شوند و چرخه حیات آن را بهطور داخلی مدیریت میکند.
شبیهسازی را میتوان با یک فراخوانی برگشتی شروع کرد که در آن شناسه بازی را بهعنوان پارامتر ارسال میکنیم.
سپس، پل آن را ازطریق 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) }این نمودار نحوه عملکرد ارتباط بین فرایندی ما را بهتر نشان میدهد.
همانطور که میبینید، بااستفاده از پل بین فعالیتها، میتوانیم آن فراخوانهای برگشتی را که ذکر کردیم مدیریت کنیم.
ما StateFlows را راهاندازی کردیم که از Unity بهروزرسانی میشوند تا مقادیری مانند وضعیت اصالتسنجی یا مُهر زمان وضعیت فعلی مسابقه را نظارت کنند.
علاوهبراین، ما رمزگذاری اطلاعات کاربری را نیز مدیریت میکنیم و هرگونه خطایی را از Unity بازارسال میکنیم تا NBA بتواند راحتتر اشکالزدایی کند و تصمیم بگیرد که چگونه آنها را در سطح برنامه به بهترین شکل مدیریت کند.
برای من همین بود.
اکنون، صحنه را به جاش برمیگردانم تا بتواند بیشتر درباره نحوه ادغام بسته Court View Unity در برنامه NBA صحبت کند.
ما از این وابستگیهای Gradle برای ادغام Unity بهعنوان کتابخانهای با برنامه Jetpack Compose خود استفاده میکنیم: XR Runtime برای دسترسی به جلسه سهبعدی، Jetpack SceneCore برای میزبانی فعالیت Unity و تغییر محیطهای فضایی، و Jetpack Compose برای 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")
}سپس، بیایید ببینیم چگونه Court View را در 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)
}
}
}قبلاز اینکه «نمای زمین» را راهاندازی کنیم، شما را به فضای کامل میبریم.
این کار برای نمایش پانل فضایی حاوی Unity لازم است.
سپس، اطلاعات بازی را ازطریق AIDL ارسال میکنیم تا «نمای دادگاه» را برای بازی خاصی راهاندازی کنیم.
سپس Unity خودش شروع میشود.
بایگانی Android ما این را بهعنوان یک intent به فعالیت Unity ارسال میکند و ما آن را در SpatialActivityPanelController میپیچیم.
از آنجا، ما فقط آن کنترلکننده را به SpatialActivityPanel منتقل میکنیم.
این پانل همان چیزی است که «نمای زمین» را نگه میدارد و در فضای فرعی ما با پانل فضایی اصلی ما همرسانی میشود.
این کار به ما امکان میدهد هر دو را بهطور همزمان و بدون هیچ مشکلی کنترل کنیم.
و همین.
آن را برای لوک پس میفرستم.
بسیار سپاسگزارم.
همانطور که میبینید، NBA توانست با تقسیم توسعه Unity و Jetpack Compose، سرعت Court View را افزایش دهد.
برای کسب اطلاعات بیشتر درباره شروع کار با محفظههای فضایی و استفاده از آن در برنامههای Android XR خودتان، به اسناد توسعهدهنده در developer.android.com/develop/xr مراجعه کنید.
از اینکه امروز به ما پیوستید بسیار سپاسگزاریم و بیصبرانه منتظریم ببینیم چه چیزی میسازید.
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)
}
}
}