Android XR

איך משפרים את חוויית המשתמש ב-Jetpack Compose ל-XR באמצעות מנועי תלת-ממד

רמקולים

מידע כללי

מידע על ההרצאה

איך משלבים מנועי משחקים בתלת-ממד (Godot ו-Unity) עם Jetpack Compose בבסיס קוד אחד לנייד ול-Android XR. נלמד איך להטמיע סצנות תלת-ממדיות בקונטיינרים מרחביים, להגדיר תקשורת בין Kotlin למנוע – וגם נציג הצצה בלעדית לאופן שבו ה-NBA בנתה את חוויית הכדורסל בשידור חי ב-Android XR.

סיכום

  • התוסף OpenXR Spatial Containers מאפשר למנועי תלת-ממד לחלוק מרחב עם ממשק המשתמש המקורי של Android. במקום להשתלט על ה-skybox, מנועים בזמן אמת פועלים בתוך נפח תלת-ממדי מנוהל לצד לוחות מרחביים של Jetpack XR.
  • מפתחים יכולים להטמיע מנועים בזמן אמת ישירות באפליקציות Jetpack Compose כספריות AAR. הגישה הזו מאפשרת ל-Android המקורי לטפל בלוגיקה העסקית ובממשק המשתמש בזמן שהמנוע מעבד רכיבי תלת-ממד.
  • כדי להטמיע את Godot, צריך תלות ב-AAR וכלל build של AAPT כדי לשמור נכסים מוסתרים. לאחר מכן, מחלקת משנה של GodotPlugin מטפלת בתקשורת דו-כיוונית עם Kotlin.
  • לולאות של ממשק משתמש ב-Compose חייבות להישאר מנותקות מלולאות של תקתוקים רציפים של המנוע. העברה עיוורת של עדכוני מנוע מפעילה הרכבה מחדש קבועה וגורמת לעיכוב בממשק המשתמש.
  • אפליקציית NBA משלבת את Unity כספרייה מוטמעת בתהליך נפרד באמצעות AIDL לתקשורת בין תהליכים, וכך מגינה על האפליקציה המארחת מפני קריסות.

משאבים

Gemini Notebook

נושאים

  • android-xr
  • jetpack-compose
  • spatial-containers
  • openxr
  • godot
  • אחדות

תמליל

הורדת התמליל

מבוא ל-Android XR

  • שלום לכולם,

  • קוראים לי לוק הופקינס.

  • אני מהנדס קשרי מפתחים בצוות Android XR.

  • בשיחה היום נדבר על איך אפשר להטמיע מנועי משחקים, כמו Godot ו-Unity, בפרויקט Android.

  • בהמשך יצטרפו אליי ג'וש ורודריגו מ-NBA ומ-Beyond Sports, והם יסבירו איך הם מטמיעים את Unity כספרייה באפליקציית NBA שלהם.

  • במפגש הזה, נתעמק באיך לשלב את Godot באפליקציית Jetpack Compose, ואז ג'וש ורודריגו יציגו את אפליקציית ה-NBA שלהם.

  • ‫Android XR היא הרחבה של הסביבה העסקית של Android, שמוסיפה מגוון רחב של גורמים פיזיים כדי להביא את החוויות שאתם מכירים ואוהבים לממד חדש.

  • הפכנו את התהליך של העברת אפליקציות קיימות ל-XR ולאפליקציות Android למרחב החדש הזה לאינטואיטיבי, ואנחנו משתמשים בטכנולוגיות Android קיימות שכבר משמשות אתכם לפיתוח.

  • ‫Android XR היא פלטפורמה מאוחדת שמיועדת למגוון רחב של מכשירים.

  • מכשירי Android XR מאפשרים סוגים שונים של חוויות.

  • יש לנו חוויות מציאות רבודה לאודיו ולמשקפיים עם תצוגה פנימית, וחוויות מציאות מדומה למשקפי VR עם Android XR ולמשקפי XR עם חיבור חוטי.

  • היום נדבר על יצירת חוויות סוחפות למשקפי XR עם חיבור חוטי ולמשקפי XR.

  • אפשר ליצור חוויות סוחפות באמצעות Jetpack XR SDK או באמצעות OpenXR עם מנוע בזמן אמת.

  • אנחנו תומכים ב-Unity, ב-Godot וב-Unreal, כדי שתוכלו ליצור חוויות סוחפות ל-Android בכל כלי שתבחרו.

  • בהרצאה הזו אסביר איך אפשר להשתמש ב-API של SceneCore ב-Jetpack, לצד Godot ותוסף OpenXR חדש, כדי לאפשר את זה.

  • כאן אפשר לראות את Godot פועל לצד החלוניות המרחביות של Jetpack שפועלות בו-זמנית.

  • שימו לב ש-Godot מוצג כתוכן מרחבי, ומספק נפח תלת-ממדי לצד ממשק משתמש דו-ממדי של Compose בתוך קונטיינר מרחבי.

  • הגישה הזו מאפשרת למפתחים להריץ סימולציות פיזיקליות והצללות בהתאמה אישית באמצעות מנוע בזמן אמת לבחירתם, תוך שימוש בשיטות פיתוח מקוריות ל-Android בשאר האפליקציה.

  • עם Android XR ו-XREAL AURA, גם אתם יכולים ליצור חוויות כאלה.

  • בשיחה הזו נלמד אתכם איך ליצור את החוויה הזו באמצעות Godot ו-Android Studio, ואז נשמע מהשותפים שלנו ב-NBA וב-Beyond Sports איך הם יצרו אותה באמצעות Unity כספרייה.

מאגרים מרחביים

  • בעבר, כדי ליצור חוויית XR עם OpenXR היה צריך ליצור חוויה שמעבדת כל פיקסל בנפרד.

  • כלומר, אם מפתח צריך להציג פעילות שנייה, הוא לא יכול להציג את שתיהן בו-זמנית.

  • כדי לפתור את הבעיה הזו, קבוצת העבודה של OpenXR, ‏ Khronos, בשיתוף עם כמה חברות מובילות בתחום, פרסמה את התוסף OpenXR Spatial Containers.

  • אפשר לחשוב על קונטיינרים מרחביים כמו OpenXR בקופסה.

  • במקום שהמנוע שלכם ינהל את כל ה-skybox, הוא מקבל נפח תלת-ממדי בניהול המערכת שיכול להתקיים לצד הלוחות המרחביים של Jetpack XR, קונטיינרים מרחביים אחרים ופעילויות רגילות של Android.

  • נניח שאתם רוצים להשתמש ב-Android XR כדי להציג שני חלונות מרחביים בו-זמנית, בזמן שאתם מפעילים משחק סוחף.

  • אתם מעבדים את כל מה שצריך במנוע, ובמקרים כאלה אפשר להפעיל את עורך Godot ואת הקונטיינר המרחבי בו-זמנית.

  • החל מרמת API 4 של Spatial API, ‏Google הטמיעה קונטיינרים מרחביים ב-Android XR.

  • קונטיינרים מרחביים תומכים בשני מצבי תצוגה חשובים.

  • הראשון הוא מצב מוגבל.

  • כך נוצר נפח תלת-ממדי מוגדר עם כמה הימנעויות מובנות.

  • המשתמש יכול לתפוס את הקונטיינר המרחבי, לסובב אותו, למקם אותו, לתפוס אותו, למזער אותו, והוא תומך בהקרנת עומק סטריאו, מה שנותן לכם את החלון הקסום הזה או את אפקט התלת-ממד הקופץ שיש לכם בזמן שאתם חותכים גם את קצוות הקונטיינר.

  • השני הוא מצב לא מוגבל.

  • אם המשתמש מחליט שהוא רוצה חוויה מלאה, האפליקציה יכולה לבקש מעבר חלק ממצב מוגבל למצב מרחב מלא, ואז אפשר גם לחזור למצב מוגבל.

  • הוספת קונטיינרים מרחביים ל-Android XR פותחת את הדלת לרעיונות חדשים ורחבים.

  • נניח שאתם רוצים להכניס את ממשק המשתמש של Android למשחק שלכם, או ליצור הצללה מותאמת אישית להצגת תוכן תלת-ממדי באפליקציה.

  • לפני שהוצגו קונטיינרים מרחביים, אי אפשר היה להפעיל את Compose ואת מנוע המשחק בו-זמנית.

  • כשמסתכלים על פיתוח אפליקציות במנועים בזמן אמת, אנחנו למעשה חיים בשתי פרדיגמות שונות.

  • למפתחים של Android, זמן הריצה של האפליקציה מבוסס על Jetpack Compose.

  • הוא מיועד לממשק משתמש מהיר ודינמי, לחיסכון בסוללה, לפריסות דינמיות, לנגישות במערכת ולהזנת טקסט חלקה.

  • הוא לא פועל כשלא קורה כלום, והוא צפוי.

  • למפתחי מנועי משחקים, התחום הוא רינדור בזמן אמת, שבו כלים כמו Godot ו-Unity מצטיינים.

  • הם לא רק מחכים לאינטראקציה עם המשתמשים.

  • הם מפעילים לולאה רציפה של תקתוק ועיבוד.

  • אז זה יכול להיות בקצב פריימים של 60, 90, 100 לשנייה.

  • הם מבצעים חישובים פיזיקליים, הצללות מורכבות, אנימציות של שלד, אודיו מרחבי, וכל זה עם ביצועים מדהימים.

גישות לשילוב מנועי תלת-ממד

  • אז מה קורה אם רוצים ליהנות מהמיטב שבשני העולמות?

  • בעבר, המפתחים היו צריכים להטמיע אחת משתי הגישות האלה.

  • אפשרות א' היא לכתוב מחדש את כל האפליקציה במנוע אמיתי כמו Godot או Unity.

  • אם תזרקו את בסיס הקוד הקיים, תאבדו הרבה מהממשק המקורי של Compose, תצטרכו להטמיע מחדש טקסט וניווט בסיסיים, ותצטרכו לכתוב מעטפת סביב ממשקי API של Android כמו המרת טקסט לדיבור (TTS).

  • אפשרות ב' היא ליצור צינור עיבוד נתונים (pipeline) לרינדור גרפיקה תלת-ממדית ברמה נמוכה מאפס בתוך אפליקציית הנייטיב, באמצעות ממשקי API גולמיים של Vulkan ו-OpenXR.

  • במקום לספק תכונות של מוצרים, אתם בעצם בונים מנוע קנייני משלכם.

  • אז היום אני יכול להראות לכם איך אתם יכולים להפיק את המרב מ-Godot כספרייה ומ-Unity כספרייה.

  • עכשיו, עם הוספת קונטיינרים מרחביים, אתם יכולים ליהנות משני העולמות.

  • כדי לעשות את זה, אנחנו יכולים להטמיע מנוע בזמן אמת ישירות בתוך ספרייה באפליקציית Jetpack.

  • אתם שומרים על המבנה של אפליקציית Android בסביבת הייצור, על הלוגיקה העסקית ועל רינדור חלק של תוכן מרחבי בתלת-ממד בתוך האפליקציה.

  • זה גם אומר שאפשר להשתמש ב-APK אחד לכל גורמי הצורה.

  • אם אתם לא רוצים להציג רק תוכן תלת-ממדי, אתם יכולים לבחור לא לטעון את הספרייה לפעילות או למקטע, לשמור על בסיס קוד נקי ולתמוך במכשירים רבים.

  • הפילוסופיה היא פשוטה: להפריד את האפליקציה ולהפוך את המנוע בזמן אמת לספרייה.

  • במקום לבחור בין אפליקציה ל-Android לבין מנוע, מתייחסים למנוע כספריית תוכנה מוטמעת, שנקראת גם תלות ב-AAR.

  • אתם יכולים למשוך את זה ישירות ל-Gradle build, בדיוק כמו כל ספרייה אחרת.

  • קוד ה-Android המקורי נשאר המוח המוחלט של האפליקציה שמארחת את רכיבי התלת-ממד.

  • ‫Jetpack Compose XR מטפל במה שהוא הכי טוב בו: רינדור של ממשק משתמש נגיש וגמיש וניהול מצב האפליקציה.

  • כשצריך רכיב תלת-ממדי, האפליקציה מארחת תצוגת רינדור מוטמעת שמרונדרת על ידי מנוע המשחק עצמו.

  • היום נסביר לעומק איך זה עובד מאחורי הקלעים ב-Godot.

  • אני אתחיל במכניקה של הטמעה ויצירת תקשורת דו-כיוונית בין Kotlin ל-GDScript.

  • ואז נבחן את התוסף החדש של OpenXR, Spatial Container, שיאפשר לכם ליהנות מהדור החדש של ריבוי משימות ופריסה מרחבית.

  • בואו נסתכל שוב על ההדגמה שהראיתי קודם.

  • יצרתי את זה באמצעות Godot ו-Android Studio.

  • בסרטון הזה אפשר לראות איך Jetpack Compose ל-XR מעבד לוחות מרחביים של ממשק משתמש דו-ממדי מקורי בשביל לוח התוצאות, ההתראות והרגעים הבולטים.

  • בנוסף, Godot מעבד סימולציה של משחק כדורגל.

תקשורת בין Kotlin ל-Godot

  • כאן אפשר לראות פירוט של התקשורת בין Kotlin ל-GDScript.

  • כדי לעשות את זה, Godot מספק ארכיטקטורת פלאגינים.

  • אתם כותבים מחלקת Kotlin קלה שמרחיבה GodotPlugin.

  • המחלקה הזו משמשת כממשק בין ה-JVM לבין סביבת זמן הריצה של Godot ב-C++‎.

  • לכן, האינטראקציות בין האפליקציה שלכם לבין Godot הן קלות מאוד.

  • הארכיטקטורה של Godot תומכת בתקשורת דו-כיוונית בין Godot ל-Kotlin.

  • כשאירוע במשחק מתרחש ב-Godot, כמו שער או עבירה, Godot יכול להפעיל ישירות פונקציות של Kotlin ולעדכן את ממשק המשתמש של Compose בזמן אמת.

  • כשהמשתמש יוצר אינטראקציה עם נגן המדיה, למשל כדי להשהות או לבחור תקצירים שונים, Kotlin שולחת אות בחזרה ל-Godot, והפעולות המתאימות של מנוע המשחק מתרחשות.

  • אחרי שמתקינים את הפלאגין של Godot, הביצועים והניהול פשוט מתבצעים לפי הדומיינים.

  • מיישמים את השיטות המומלצות לאפליקציות ל-Android ואת השיטות המומלצות לפרויקטים של Godot.

  • עכשיו נבחן את הארכיטקטורה.

  • בואו נראה איך אנחנו מחברים את זה ל-Godot באפליקציה ל-Android.

  • סביבת זמן הריצה של מנוע Godot מופצת דרך Maven כ-AAR.

  • כדי לשלב את ה-SDK ב-build, פשוט פועלים לפי השיטות המומלצות הרגילות של Android.

  • יש שלב אחד חשוב שצריך לשים לב אליו מיד, ואני קורא לו "מלכודת הנכסים".

    androidResources {
         ignoreAssetsPattern = "!.svn:!.git:!.gitignore:!.ds_store:!*.scc:<dir>_*:!CVS:!*~:!android:!export_presets.cfg"
     }
  • בפרויקט ב-Godot, הסצנות וקובצי התצורה של הפרויקט נמצאים באותו מקום.

  • כשמטמיעים את Godot, הפרויקט של Godot מתווסף ישירות לתיקיית הנכסים.

  • כלומר, כל הרשתות התלת-ממדיות, הטקסטורות הדחוסות והטקסטורות המקומפלות שיובאו נשמרות כפריט מידע שנוצר בזמן ריצה בקובץ מוסתר בשם ‎ .godot.

  • כברירת מחדל, מערכת Android תסיר את התיקייה הזו במהלך ה-build, כך שהיא מתעלמת מכל ספרייה עם נקודה.

  • לכן, אם תסנכרנו ותבנו את האפליקציה כרגיל, ה-APK ייבנה, אבל הוא יקרוס וייטען כי הדברים האלה לא קומפלו ל-APK.

  • כדי לפתור את הבעיה, אפשר פשוט לשנות באופן מפורש את האפשרויות של AAPT בתוך build.gradle ולאפשר את הנקודות בתחילת השם מ-Godot.

  • אחרי שתעשו את זה, תוכלו להריץ את הסצנה של Godot פעם אחת בעורך של Godot, ומשם תוכלו לבנות את כל השאר מ-Android Studio.

  • כדי לתמוך בקונטיינרים מרחביים ובגרסה העדכנית של OpenXR, מומלץ להשתמש ב-Godot 4.8 ואילך, בתוספים של ספק Godot בגרסה 5.2 וב-Khronos OpenXR loader בגרסה 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 ב-Jetpack Compose

  • אחרי שהנכסים נארזים, איך 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, שבו אפשר לקבל את המופע של הפלאגין הזה.

    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, פשוט באמצעות המופע של הפלאגין.

    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 קורא ל-onMatchEvent, השם ב-Kotlin צריך להיות זהה לחלוטין, כך שאין המרה אוטומטית לקאמל קייס או לקו תחתון בין המילים.

  • לכן, אתם רק צריכים לוודא ששתי השיטות זהות לחלוטין.

  • אחרי שמחברים את הפלאגין, החלק הכי חשוב בהרצאה הזו הוא מה שאני מכנה "המודל המנטלי".

  • כאן מנועי משחקים ו-Jetpack Compose פועלים בשתי ארכיטקטורות מנוגדות, פחות או יותר.

  • מנוע משחק פועל על טיק רציף של פריימים.

  • גם אם לא שיניתם כלום, תהליך הטיק של מנוע המשחק מתרחש 60, 90 או 100 פעמים בשנייה, ומספק עיבוד עשיר וחלק בזמן אמת.

  • מנועים כמו Godot מנצלים ביעילות את מחזורי ה-GPU וה-CPU.

  • לעומת זאת, Jetpack Compose מטפל בדברים באופן ריאקטיבי והוא מבוסס-מצב.

  • וכשהאפליקציה לא משתנה, פיתוח נייטיב ממשיך וצורך 0 CPU.

  • אם רוצים לחבר ביניהם כדי להריץ שתי סביבות זמן ריצה בלי אסטרטגיה ברורה, הגישות האלה נוטות להתנגש.

  • אז בואו נראה איך אפשר לעקוף את הבעיה הזו באמצעות מה שאני מכנה "דליפת טיקים".

  • כאן יש לנו חוויה תלת-ממדית, נניח הטיימר של לוח התוצאות.

  • בסקריפט של המנוע יש משתנה של טיימר שמשתנה כל הזמן.

  • ואם תעבירו את המשתנה הזה ל-Compose בלי לבדוק, נניח 90 פעמים בשנייה, תיכנסו ללולאת ה-recomposition הזו 90 פעמים בשנייה.

  • כלומר, Compose יעריך מחדש את הרכיבים הקומפוזביליים, ימדוד מחדש את הפריסות וישרטט מחדש את עצי ממשק המשתמש 90 פעמים בשנייה.

  • אתם תראו דילוגים על פריימים, עיכובים בממשק המשתמש, והמכשיר יתחמם מהר מאוד.

  • לכן, כלל הזהב הוא אף פעם לא לתת ללולאת המנוע הרציפה להכתיב את לולאת ממשק המשתמש הריאקטיבי.

  • בדוגמה הזו, במקום ש-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 צריכים לפעול ב-thread הראשי של 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, שהם אחד מהשותפים הראשונים שמשתמשים בקונטיינרים מרחביים באפליקציה שלהם.

סקירה כללית של NBA Court View

  • תודה, Luke.

  • שלום לכולם,

  • קוראים לי ג'וש, ואני מפתח Android ב-NBA.

  • אני כאן היום עם הקולגה שלי, רודריגו סנצ'ז, מפתח תוכנה ב-Unity ב-Beyond Sports.

  • היום אנחנו רוצים להראות לכם איך השתמשנו ב-Unity as a Library כדי ליצור את Court View לאפליקציית NBA.

  • ב-NBA אנחנו תמיד מחפשים דרכים להביא חוויות חדשות לאוהדים שלנו.

  • לכן, בשיתוף עם Google ו-Beyond Sports, אנחנו שמחים להציג את Court View.

  • התכונה "מבט מהמגרש" מציעה שחזורים תלת-ממדיים של משחקי NBA, ומאפשרת למנויי League Pass לצפות במשחקים מנקודות מבט חדשות שלא אפשריות בסרטוני שידור רגילים.

  • משנים את הגודל והזווית של המגרש וממקמים אותו איפה שרוצים.

  • אפשר להסתובב סביבו ולצפות במשחק מזוויות ייחודיות.

  • בזמן הזה, אתם יכולים להשתמש באפליקציית NBA כרגיל, החל מצפייה בנתונים סטטיסטיים מסונכרנים ועד לצפייה בשידור החי.

  • איך מגדירים את זה?

  • התצוגה Court View מעובדת ב-Unity, ואפליקציית Android שלנו משתמשת ב-Unity כספרייה.

  • לאחר מכן אנחנו יוצרים קובץ Android Archive אחד, שמתארח ב-Maven Central, ואפליקציית NBA יכולה למשוך אותו כתלות אחת.

  • זה אפשר לנו לפצל את העבודה בין הצד של Android לצד של Unity.

  • כך כל אחד מאיתנו יכול להתמקד במה שהוא יודע הכי טוב, ולעבוד במקביל כדי להאיץ את הפיתוח באופן כללי.

  • לדוגמה, בזמן שהצוות שלנו עבד על חוויית המרחב המלא ב-Compose, הצוות של Unity יכול היה להתמקד בשינוי קנה המידה ובפריסה של המגרש התלת-ממדי.

  • עכשיו אעביר את רשות הדיבור לרודריגו, שיספר עוד על הפרויקט של Unity ועל האופן שבו הם עבדו עם Unity as a Library.

שילוב Unity כספרייה

  • תודה, ג'וש.

  • אנחנו ב-Beyond Sports עובדים עם ה-NBA כבר כמה שנים על הצגת הדמיות תלת-ממדיות עם נתונים אמיתיים ממשחקים.

  • בפרויקט הזה, התאמנו את הסימולציה התלת-ממדית שלנו ל-Android XR.

  • הסימולציה שלנו מבוססת על מנוע Unity עם C#‎.

  • אנחנו מייצאים את הסימולציה ל-Android באמצעות Unity כספרייה, ויוצרים שכבת תקשורת לצדה באמצעות Kotlin.

  • לפני שמאגדים את שני המודולים האלה יחד כתלות אחת של Maven, כדי ש-NBA יוכלו לכלול אותם באפליקציה שלהם.

  • במסך אפשר לראות סרטון קצר של אותה סימולציה שג'וש הראה קודם, שפועלת בעורך.

  • כדי לעשות את זה, אנחנו משתמשים בחבילת Android XR Extensions ל-Unity.

  • אני אסביר לך מה צריך לעשות.

  • לכן, לפני שיוצרים ב-Unity, חשוב לוודא שבחרתם את ההגדרות ואת יישומי הפלאגין הנכונים.

  • מורידים את חבילת XR Plug-in Management של Unity ואת חבילת Android XR Extensions for Unity של Google.

  • צריך להגדיר אותם בהגדרות של XR Plug-in Management (ניהול פלאגינים של XR) כדי להשתמש בספק OpenXR Android XR Extensions.

  • במקרה שלנו, רצינו להשתמש ב-Spatial Containers, אז הפעלנו את התכונה הזו והשבתנו את immersive XR מהגדרות ניהול הסשנים.

  • לא השתמשנו בחבילת קלט XR, כי כל הקלט מטופל באופן מקורי, מחוץ ל-Unity.

  • בשלב הבא, בהגדרות הנגן, צריך להגדיר את הפרויקט כך שישתמש ברמת API מינימלית של 24 ומעלה, ואת נקודת הכניסה של האפליקציה צריך להגדיר ל-Game Activity החדש יותר.

  • חשוב לוודא שהתיבה "פעילות" לא מסומנת.

  • אנחנו רוצים לפתח רק ל-ARM64, אז אנחנו בוחרים בארכיטקטורת היעד הזו. בקטע הרזולוציה, אנחנו מפעילים את האפשרות Run in Background (הפעלה ברקע), כדי שתוכלו לבצע כמה משימות במקביל באפליקציות אחרות, ולהמשיך להריץ את הסימולציה בתלת-ממד ברקע.

  • אנחנו גם מפעילים את Resizeable Activity, כי היא נדרשת כדי להציג תיבות דו-שיח, כמו בקשות הרשאה של המערכת.

  • בנוסף, מומלץ לרנדר אפליקציות ל-Android XR באמצעות Universal Render Pipeline ו-Vulkan Graphics API.

  • בנכס ה-URP, צריך להשבית את ה-HDR ואת העיבוד לאחר הצילום.

  • אחרי שתסיימו את כל השלבים האלה, תוכלו ליצור גרסה.

  • עוברים לחלון ה-build. אם משתמשים ב-Unity 6.1 או בגרסה חדשה יותר, אפשר לבחור את פרופיל ה-build של Android XR, שמאפשר להפריד את ההגדרות אם מפתחים גם ל-Android Mobile.

  • לפני שיוצרים את הפרויקט, חשוב לוודא שהפעלתם את האפשרות לייצא אותו.

  • אחרי ש-Unity תסיים את ה-build, יהיה לכם פרויקט Android פעיל שאפשר להרכיב ב-Android Studio ולהפעיל במכשיר כמו כל קובץ APK אחר של Unity.

  • ב-Android Studio, צריך להפוך את ה-build שייצאתם לספרייה שתשמש אפליקציה אחרת.

    // 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 הראשי לבדיקה כפלייסהולדר מדומה לאפליקציית ה-wrapping של NBA, מודול Unity Library שמכיל את ה-build שייצאנו, מודול nativeCommunications שמכיל את שכבת התקשורת שבנינו בין ה-Activity, ומודול packageExporter, שבו אנחנו משתמשים כדי למזג את ה-AAR שלנו לחבילה אחת כדי להעביר ל-Maven.

  • כדי למזג כמה קובצי AAR, אפשר להשתמש ב-Android Fused Library או בשיטות קהילתיות ישנות יותר כמו Fat AAR.

  • כדי לשמור על מבנה ותקשורת פשוטים, אפשר להפעיל את Unity ואת הפעילות הראשית באותו תהליך.

  • החלטנו לא לעשות את זה, ובמקום זאת להפריד את הפעילות של Unity לתהליך משלה כאמצעי הגנה. כך, אם החלק של Unity יקרוס, אפליקציית NBA לא תושפע ותוכל לטפל בסגירה של Unity בצורה חלקה בלי לקרוס בעצמה.

  • ההחלטה הזו מאלצת אותנו להשתמש במערכת חזקה יותר לתקשורת בין תהליכים.

  • מכיוון שהפעילויות נמצאות בתהליכים שונים, אין זיכרון משותף ביניהן, ושיטות רבות של Android IPC לא פועלות או לא עומדות בדרישות.

  • אנחנו משתמשים בשפת ההגדרה לבניית ממשק 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 שהגדרנו, והוא גם מספק דרך קלה לפעילויות להתחבר לתהליך, סליחה, לשירות, ומנהל באופן פנימי את מחזור החיים שלו.

  • כדי להתחיל סימולציה, אפשר להשתמש בקריאה חוזרת (callback) שבה מעבירים את מזהה המשחק כפרמטר.

  • לאחר מכן, הגשר מעביר אותו דרך AIDL, ואז לאפליקציית Unity, שבה C# ‎ מקבל פקודה ומתחיל להגדיר את הסצנה.

    public class NativeInterfaceListener : AndroidJavaProxy {
    [UsedImplicitly]
    public void startMatch(string matchID) {
       UnityThreadHelper.InvokeOnMain(() => _internalAPI.StartMatch(matchID));
    }
    }
  • אנחנו צריכים לחכות ש-Unity יהיה פעיל ומוכן ללחוץ על הפקודות שאנחנו מעבירים אליו.

  • הפעילות של Unity שעוטפת את האפליקציה תהיה פעילה לפני Unity עצמה.

  • אם הפקודות נשלחות מוקדם מדי, אנחנו שומרים אותן בתור של פונקציות קריאה חוזרת (callback).

    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 כדי לגשת לסשן התלת-ממדי, 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)
            }
        }
    }
  • לפני שנפעיל את Court View, נעביר אתכם למרחב מלא.

  • ההרשאה הזו נדרשת כדי להציג את החלונית המרחבית שבה נמצאת Unity.

  • בשלב הבא, אנחנו שולחים את פרטי המשחק דרך AIDL כדי להפעיל את Court View למשחק ספציפי.

  • לאחר מכן, Unity מופעלת.

  • הארכיון שלנו ל-Android שולח את זה כ-intent לפעילות ב-Unity, ואנחנו עוטפים את זה ב-SpatialActivityPanelController.

  • משם, אנחנו פשוט מעבירים את הבקר הזה אל SpatialActivityPanel.

  • החלונית הזו מכילה את Court View, והיא משותפת במרחב המשנה שלנו עם החלונית המרחבית הראשית.

  • כך אנחנו יכולים לשלוט בשניהם בו-זמנית בלי בעיות.

  • וזהו.

  • אני אשלח אותה בחזרה ללוק.

סיכום ומקורות מידע

  • תודה רבה.

  • כמו שאתם רואים, ב-NBA הצליחו להאיץ את הפיתוח של Court View על ידי פיצול הפיתוח של Unity ו-Jetpack Compose.

  • במאמרי העזרה למפתחים בכתובת developer.android.com/develop/xr מוסבר איך להתחיל להשתמש בקונטיינרים מרחביים באפליקציות Android XR.

  • תודה רבה שהצטרפתם אלינו היום, ואנחנו כבר מצפים לראות מה תפתחו.

קוד

קטעי קוד

קטע קוד של Kotlin Script בדקה 09:30 בסרטון "Communication between Kotlin and Godot"
androidResources {
     ignoreAssetsPattern = "!.svn:!.git:!.gitignore:!.ds_store:!*.scc:<dir>_*:!CVS:!*~:!android:!export_presets.cfg"
 }
קטע קוד של Kotlin Script בדקה 10:27 בסרטון "תקשורת בין Kotlin ל-Godot"
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")
קטע קוד ב-Kotlin בדקה 10:48 בסרטון "Rendering Godot in Jetpack Compose"
@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 }
    )
}
קטע קוד ב-Kotlin בדקה 10:55 בסרטון "רינדור של Godot ב-Jetpack Compose"
Subspace {
        SpatialPanel(
            modifier = SubspaceModifier
                .width(960.dp)
                .height(540.dp)
                .offset(z = (-200).dp)
                .movable()
,
    ) {
        GodotView(
            godotHost = godotHost,
            modifier = Modifier.fillMaxSize(),
        )
    }
}
קטע קוד ב-Kotlin בדקה 11:05 בסרטון Rendering Godot in Jetpack Compose
@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)
       }
   }

}
קטע קוד ב-Kotlin בדקה 11:29 בסרטון "רינדור של Godot ב-Jetpack Compose"
class MainActivity : ComponentActivity(), GodotHost {
    override fun getHostPlugins(engine: Godot): Set<GodotPlugin> =
        setOf(SoccerGodotPlugin(engine))
}
קטע קוד ב-Kotlin בדקה 11:52 בסרטון "רינדור של Godot ב-Jetpack Compose"
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),
    )

}
קטע טקסט בדקה 12:01 בסרטון "עיבוד Godot ב-Jetpack Compose"
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)
קטע קוד ב-Kotlin בדקה 12:10 בסרטון "רינדור של Godot ב-Jetpack Compose"
class SoccerGodotPlugin(godot: Godot) : GodotPlugin(godot) {

    fun sendCommandToGodot(command: String) {
        emitSignal("match_command", command)
    }
}
קטע טקסט ב-12:20 בסרטון "עיבוד Godot ב-Jetpack Compose"
func _on_goal_scored(minute: int, home: int, away: int) -> void:

    if soccer_plugin:
        soccer_plugin.onMatchEvent("GOAL", minute, home, away)
קטע קוד ב-Kotlin בדקה 12:30 בסרטון Rendering Godot in Jetpack Compose
@UsedByGodot
fun onMatchEvent(type: String, minute: Int, home: Int, away: Int, title: String) {

        _uiState.update { state ->
            state.copy(homeScore = home, awayScore = away, matchSeconds = minute)
        }
    }
קטע קוד ב-Kotlin בדקה 14:35 בסרטון "רינדור של Godot ב-Jetpack Compose"
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, ...) }
}
קטע קוד ב-Kotlin בדקה 14:57 בסרטון "רינדור של Godot ב-Jetpack Compose"
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
       )
   }
קטע קוד XML בדקה 15:22 בסרטון Rendering Godot in Jetpack Compose
<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="" />
קטע קוד של Kotlin Script בדקה 20:41 בסרטון Integrating Unity as a library
// 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")
}
}
קטע קוד ב-Kotlin בדקה 22:11 בסרטון "שילוב Unity כספרייה"
// 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# ‎ בדקה 22:36 בסרטון Integrating Unity as a library
public class NativeInterfaceListener : AndroidJavaProxy {
[UsedImplicitly]
public void startMatch(string matchID) {
   UnityThreadHelper.InvokeOnMain(() => _internalAPI.StartMatch(matchID));
}
}
קטע קוד ב-Kotlin בדקה 22:54 בסרטון "Integrating Unity as a library"
fun onStartMatchClickedInternal() {
   if (isServiceBound) {
       AIDLBridgeService.startMatch("match_id")
   } else {
       delayedCallbacksQueue.add(action)
   }
}
קטע קוד ב-C# ‎ בדקה 22:57 בסרטון "Integrating Unity as a library"
// After bootload and initialization

using AndroidJavaClass nativeBridge = new("com.company.UnityActivity");
using AndroidJavaObject instance = nativeBridge.GetStatic<AndroidJavaObject>("instance");
instance.Call("onUnityReady");
קטע קוד ב-Kotlin ב-23:06 בסרטון "Integrating Unity as a library"
@Suppress("unused") //  Unity implicit call
fun onUnityReady() {
   bridge.activityBridgeServiceInternal?.onUnityReady()
}

delayedCallbacksQueue.forEach { it(activityBridgeService) }
קטע קוד של Kotlin Script בדקה 23:50 בסרטון "Integrating Unity as a library"
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")
}
קטע קוד ב-Kotlin בדקה 24:10 בסרטון Integrating Unity as a library
// 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)
        }
    }
}