Android XR

ยกระดับประสบการณ์ที่สมจริงด้วยเครื่องมือ 3 มิติใน Jetpack Compose สำหรับ XR

ลำโพง

เกี่ยวกับ

เกี่ยวกับการเสวนา

ดูวิธีผสานรวมเกมเอนจิน 3 มิติ (Godot และ Unity) กับ Jetpack Compose ไว้ในฐานของโค้ดเดียวสำหรับอุปกรณ์เคลื่อนที่และ Android XR เราจะสำรวจการฝังฉาก 3 มิติลงในคอนเทนเนอร์เชิงพื้นที่ การตั้งค่าการสื่อสารระหว่าง Kotlin กับเครื่องมือ พร้อมทั้งเจาะลึกวิธีที่ NBA สร้างประสบการณ์การรับชมบาสเกตบอลแบบสดบน Android XR

สรุป

  • ส่วนขยาย OpenXR Spatial Containers ช่วยให้เอนจิน 3 มิติแชร์พื้นที่กับ UI ของ Android แบบเนทีฟได้ เครื่องมือแบบเรียลไทม์จะทำงานภายในวอลุ่ม 3 มิติที่มีการจัดการควบคู่ไปกับแผงเชิงพื้นที่ Jetpack XR แทนที่จะใช้สกายบ็อกซ์
  • นักพัฒนาแอปสามารถฝังเครื่องมือแบบเรียลไทม์ลงในแอปพลิเคชัน Jetpack Compose ได้โดยตรงเป็นไลบรารี AAR แนวทางนี้ช่วยให้ Android แบบเนทีฟจัดการตรรกะทางธุรกิจและ UI ได้ในขณะที่เครื่องมือแสดงผลคอมโพเนนต์ 3 มิติ
  • การฝัง Godot ต้องใช้ทรัพยากร Dependency ของ AAR และกฎการสร้าง AAPT เพื่อเก็บชิ้นงานที่ซ่อนไว้ จากนั้นคลาสย่อย GodotPlugin จะจัดการการสื่อสารแบบ 2 ทิศทางกับ Kotlin
  • ลูป UI ของ Compose ต้องแยกออกจากลูปเครื่องมือ Tick แบบต่อเนื่อง การส่งต่อการอัปเดตเครื่องมือโดยไม่ตรวจสอบจะทริกเกอร์การจัดองค์ประกอบใหม่ตลอดเวลาและทำให้ UI เกิดความล่าช้า
  • แอป NBA ผสานรวม Unity เป็นไลบรารีแบบฝังในกระบวนการแยกต่างหากโดยใช้ AIDL สำหรับการสื่อสารระหว่างกระบวนการ ซึ่งช่วยปกป้องแอปโฮสต์ไม่ให้ขัดข้อง

แหล่งข้อมูล

Gemini Notebook

หัวข้อ

  • android-xr
  • jetpack-compose
  • spatial-containers
  • openxr
  • godot
  • ความสามัคคี

ข้อความถอดเสียง

ข้อมูลเบื้องต้นเกี่ยวกับ Android XR

  • สวัสดีทุกคน

  • ฉันชื่อ Luke Hopkins

  • ฉันเป็นวิศวกรนักพัฒนาซอฟต์แวร์สัมพันธ์ในทีม Android XR

  • ในการพูดคุยวันนี้ เราจะพูดถึงวิธีฝังเกมเอนจิน เช่น Godot และ Unity ลงในโปรเจ็กต์ Android

  • หลังจากนั้น Josh และ Rodrigo จาก NBA และ Beyond Sports จะมาร่วมพูดคุยเกี่ยวกับวิธีที่พวกเขานำ Unity ไปใช้เป็นไลบรารีในแอปพลิเคชัน NBA

  • ในเซสชันนี้ เราจะเจาะลึกถึงวิธีผสานรวม Godot เข้ากับแอปพลิเคชัน Jetpack Compose ด้วย จากนั้น Josh และ Rodrigo จะพูดถึงแอปพลิเคชัน NBA ของตน

  • Android XR เป็นส่วนขยายของระบบนิเวศของ Android ซึ่งขยายรูปแบบของอุปกรณ์ต่างๆ เพื่อนำประสบการณ์ที่คุณรู้จักและชื่นชอบมาสู่มิติใหม่

  • เราได้ทำให้การนำแอป XR และ Android ที่มีอยู่มาใช้ในพื้นที่ใหม่นี้เป็นเรื่องง่าย และเรากำลังใช้ประโยชน์จากเทคโนโลยี Android ที่มีอยู่ซึ่งคุณใช้สร้างแอปในปัจจุบัน

  • Android XR เป็นแพลตฟอร์มแบบรวมที่ออกแบบมาสำหรับอุปกรณ์หลากหลายประเภท

  • อุปกรณ์ Android XR ขับเคลื่อนประสบการณ์ประเภทต่างๆ

  • เรามีประสบการณ์การใช้งานเทคโนโลยีความจริงเสริม (AR) สำหรับแว่นตาที่มีจอแสดงผลและเสียง และเรามีประสบการณ์การใช้งานแบบสมจริงสำหรับชุดหูฟัง XR และแว่นตา XR แบบใช้สาย

  • วันนี้เราจะพูดถึงการสร้างประสบการณ์ที่สมจริงสำหรับแว่นตา XR แบบมีสายและชุดหูฟัง XR

  • การสร้างประสบการณ์การใช้งานที่สมจริงสามารถทำได้ผ่าน Jetpack XR SDK หรือใช้ OpenXR กับเครื่องมือแบบเรียลไทม์

  • เรารองรับ Unity, Godot และ Unreal เพื่อให้คุณสร้างประสบการณ์ที่สมจริงสำหรับ Android ได้ไม่ว่าจะใช้เครื่องมือใดก็ตาม

  • ในการพูดคุยครั้งนี้ ฉันจะอธิบายวิธีใช้ประโยชน์จาก API SceneCore ของ Jetpack ควบคู่ไปกับ Godot และส่วนขยาย OpenXR ใหม่เพื่อให้เป็นไปได้

  • คุณจะเห็น Godot ทำงานควบคู่ไปกับแผงเชิงพื้นที่ของ Jetpack ที่ทำงานพร้อมกัน

  • สังเกตว่า Godot แสดงผลเป็นเนื้อหาเชิงพื้นที่อย่างไร โดยให้ปริมาตร 3 มิติควบคู่ไปกับ Compose UI แบบ 2 มิติภายในคอนเทนเนอร์เชิงพื้นที่

  • แนวทางนี้ช่วยให้นักพัฒนาแอป Android เรียกใช้การจำลองทางฟิสิกส์และเฉดสีที่กำหนดเองโดยใช้เครื่องมือแบบเรียลไทม์ที่เลือกได้ ขณะเดียวกันก็ใช้แนวทางปฏิบัติในการพัฒนา Android แบบเนทีฟกับส่วนที่เหลือของแอปพลิเคชันได้

  • คุณก็สร้างประสบการณ์แบบนี้ได้ด้วย Android XR และ XREAL AURA

  • ในการพูดคุยครั้งนี้ เราจะสอนวิธีสร้างประสบการณ์นี้โดยใช้ Godot และ Android Studio จากนั้นคุณจะได้ฟังพาร์ทเนอร์ของเราจาก NBA และ Beyond Sports เกี่ยวกับวิธีสร้างประสบการณ์นี้โดยใช้ Unity เป็นไลบรารี

คอนเทนเนอร์เชิงพื้นที่

  • ในอดีต การสร้างประสบการณ์ XR ด้วย OpenXR หมายถึงการสร้างประสบการณ์ที่แสดงผลทุกพิกเซล

  • ซึ่งหมายความว่าหากนักพัฒนาแอปต้องการแสดงกิจกรรมที่ 2 ก็จะแสดงทั้ง 2 กิจกรรมพร้อมกันไม่ได้

  • เพื่อแก้ไขปัญหานี้ Khronos ซึ่งเป็นกลุ่มทำงาน OpenXR ได้ร่วมมือกับผู้นำในอุตสาหกรรมหลายรายเพื่อเปิดตัวส่วนขยาย OpenXR Spatial Containers

  • ลองนึกภาพคอนเทนเนอร์เชิงพื้นที่เป็น OpenXR ในกล่อง

  • แทนที่เครื่องมือของคุณจะเป็นเจ้าของ Skybox ทั้งหมด ระบบจะให้ปริมาตร 3 มิติที่จัดการโดยระบบซึ่งสามารถอยู่ร่วมกับแผงเชิงพื้นที่ XR ของ Jetpack, คอนเทนเนอร์เชิงพื้นที่อื่นๆ และกิจกรรม Android มาตรฐานได้

  • สมมติว่าคุณต้องการใช้ประโยชน์จาก Android XR โดยแสดงแผงเชิงพื้นที่ 2 แผงพร้อมกันขณะเล่นเกมที่สมจริง

  • คุณแสดงผลทุกอย่างในเครื่องมือ และทำให้สามารถใช้เครื่องมือแก้ไข Godot และคอนเทนเนอร์เชิงพื้นที่ทำงานพร้อมกันได้ในกรณีการใช้งานเหล่านี้

  • ตั้งแต่ Spatial API ระดับ 4 เป็นต้นไป Google ได้นำคอนเทนเนอร์เชิงมิติพื้นที่มาใช้ใน Android XR

  • คอนเทนเนอร์เชิงมิติพื้นที่รองรับโหมดการแสดงผลที่สำคัญ 2 โหมด

  • โหมดแรกคือโหมดที่มีขอบเขต

  • ซึ่งจะให้ปริมาตร 3 มิติที่กำหนดไว้พร้อมกับการหลีกเลี่ยงในตัวหลายรายการ

  • ผู้ใช้สามารถจับคอนเทนเนอร์เชิงพื้นที่ หมุน จัดตำแหน่ง จับ ย่อขนาด และรองรับการฉายภาพความลึกแบบสเตอริโอ ซึ่งจะทำให้คุณได้หน้าต่างมหัศจรรย์หรือเอฟเฟกต์ป๊อปเอาต์ 3 มิติที่คุณมีขณะตัดขอบของคอนเทนเนอร์ด้วย

  • ส่วนวิธีที่ 2 คือโหมดไม่จำกัด

  • หากผู้ใช้ตัดสินใจว่าต้องการประสบการณ์ที่สมจริงเต็มรูปแบบ แอปของคุณสามารถขอเปลี่ยนจากโหมดที่มีขอบเขตเป็นโหมด Full Space ได้อย่างราบรื่น และคุณยังกลับไปใช้โหมดที่มีขอบเขตได้ด้วย

  • การเพิ่มคอนเทนเนอร์เชิงมิติพื้นที่ทำให้ Android XR เปิดโอกาสให้เกิดแนวคิดใหม่ๆ ที่กว้างขวาง

  • สมมติว่าคุณต้องการนำ Android UI มาใช้ในเกม หรืออาจต้องการสร้างเฉดสีที่กำหนดเองเพื่อแสดงเนื้อหา 3 มิติในแอปพลิเคชัน

  • ก่อนที่จะมีการเปิดตัวคอนเทนเนอร์เชิงพื้นที่ คุณไม่สามารถเรียกใช้ทั้ง Compose และเกมเอนจินพร้อมกันได้

  • และเมื่อพิจารณาการพัฒนาแอปพลิเคชันในเครื่องมือแบบเรียลไทม์ เราก็ใช้ชีวิตอยู่ในกระบวนทัศน์ที่แตกต่างกัน 2 แบบด้วย

  • สำหรับนักพัฒนาแอป Android แบบเนทีฟ รันไทม์ของแอปพลิเคชันจะสร้างขึ้นโดยใช้ Jetpack Compose

  • ซึ่งออกแบบมาเพื่อ UI ที่รวดเร็วและตอบสนองได้ดี ประสิทธิภาพแบตเตอรี่ เลย์เอาต์แบบไดนามิก การช่วยเหลือพิเศษของระบบ และการป้อนข้อความที่ราบรื่น

  • โดยจะไม่มีการทำงานเมื่อไม่มีเหตุการณ์เกิดขึ้น และคาดเดาได้

  • สำหรับนักพัฒนาเกมเอนจิน โดเมนคือการแสดงผลแบบเรียลไทม์ ซึ่งเครื่องมืออย่าง Godot และ Unity มีบทบาทสำคัญ

  • โดยไม่เพียงแค่รอให้ผู้ใช้โต้ตอบเท่านั้น

  • โดยจะเรียกใช้ Tick และ Render Loop อย่างต่อเนื่อง

  • ดังนั้นจึงสามารถใช้อัตราเฟรม 60, 90, 100 เฟรมต่อวินาทีได้

  • โดยจะทำการคำนวณทางฟิสิกส์, ตัวปรับเฉดสีที่ซับซ้อน, ภาพเคลื่อนไหวแบบโครงกระดูก, เสียงรอบทิศทาง และทั้งหมดนี้มีประสิทธิภาพที่น่าทึ่ง

แนวทางในการผสานรวมเครื่องมือ 3 มิติ

  • แล้วจะเกิดอะไรขึ้นหากคุณต้องการใช้ประโยชน์จากทั้ง 2 บทบาท

  • ก่อนหน้านี้นักพัฒนาแอปต้องใช้วิธีใดวิธีหนึ่งจาก 2 วิธีนี้

  • ตัวเลือก ก คือการเขียนแอปพลิเคชันทั้งหมดใหม่ภายในเครื่องมือจริง เช่น Godot หรือ Unity

  • การทิ้งฐานของโค้ดที่มีอยู่หมายความว่าคุณจะสูญเสีย Compose UI แบบเนทีฟไปเป็นจำนวนมาก ต้องติดตั้งใช้งานข้อความและการนำทางพื้นฐานอีกครั้ง และเขียน Wrapper รอบ Android API เช่น การอ่านออกเสียงข้อความ

  • ตัวเลือก B คือการสร้างไปป์ไลน์การแสดงผลกราฟิก 3 มิติระดับต่ำตั้งแต่ต้นภายในแอปที่มาพร้อมเครื่องโดยใช้ Vulkan และ API ของ OpenXR แบบข้อมูลดิบ

  • แทนที่จะนำเสนอฟีเจอร์ของผลิตภัณฑ์ คุณกำลังสร้างกลไกที่เป็นกรรมสิทธิ์ของคุณเอง

  • วันนี้ผมจะมาแสดงวิธีใช้ประโยชน์สูงสุดจาก Godot as a library และ Unity as a library

  • และตอนนี้คุณสามารถใช้ประโยชน์จากทั้ง 2 อย่างได้ด้วยการเพิ่มคอนเทนเนอร์เชิงพื้นที่

  • เราสามารถทำได้โดยการฝังเครื่องมือแบบเรียลไทม์ไว้ในไลบรารีภายในแอปพลิเคชัน Jetpack โดยตรง

  • คุณยังคงรักษาโครงสร้างแอป Android เวอร์ชันที่ใช้งานจริง ตรรกะทางธุรกิจ และการแสดงผลเนื้อหาเชิงพื้นที่ 3 มิติที่ราบรื่นภายในแอปพลิเคชันไว้ได้

  • ซึ่งหมายความว่าคุณสามารถใช้ APK เดียวสำหรับอุปกรณ์ทุกรูปแบบได้ด้วย

  • ในกรณีที่คุณไม่ต้องการแสดงเฉพาะเนื้อหา 3 มิติ คุณสามารถเลือกที่จะไม่โหลดไลบรารีลงในกิจกรรมหรือ Fragment และทำให้ฐานของโค้ดชัดเจน ซึ่งจะช่วยให้คุณรองรับอุปกรณ์ได้หลายเครื่อง

  • ปรัชญานี้ตรงไปตรงมา โดยแยกแอปพลิเคชันและทำให้เครื่องมือแบบเรียลไทม์เป็นไลบรารี

  • แทนที่จะเลือกระหว่างแอป Android หรือเครื่องมือ คุณจะถือว่าเครื่องมือเป็นไลบรารีซอฟต์แวร์แบบฝัง หรือที่เรียกว่าทรัพยากร Dependency ของ AAR

  • คุณสามารถดึงข้อมูลนี้ลงในบิลด์ Gradle ได้โดยตรง เช่นเดียวกับไลบรารีอื่นๆ

  • โค้ด Android แบบเนทีฟจะยังคงเป็นสมองหลักของแอปพลิเคชันที่โฮสต์คอมโพเนนต์ 3 มิติ

  • Jetpack Compose XR จะจัดการสิ่งที่ทำได้ดีที่สุด นั่นคือการแสดงผล UI แบบไหลที่เข้าถึงได้แบบเนทีฟและการจัดการสถานะแอป

  • และเมื่อจำเป็นต้องใช้องค์ประกอบ 3 มิติ แอปจะโฮสต์มุมมองการแสดงผลแบบฝังที่แสดงผลโดยเกมเอนจินเอง

  • วันนี้เราจะมาเจาะลึกวิธีการทำงานเบื้องหลังของ Godot กัน

  • และฉันจะเริ่มด้วยกลไกการฝังและการสร้างการสื่อสารแบบ 2 ทิศทางระหว่าง Kotlin และ GDScript

  • จากนั้นเราจะมาดูส่วนขยาย OpenXR ใหม่ที่ชื่อว่า Spatial Container ซึ่งจะปลดล็อกการทำงานหลายอย่างพร้อมกันและเลย์เอาต์เชิงพื้นที่รุ่นใหม่ได้อย่างแท้จริง

  • มาดูการสาธิตที่เราแสดงให้เห็นก่อนหน้านี้กันแบบเร็วๆ

  • ฉันสร้างเกมนี้โดยใช้ Godot และ Android Studio

  • และคลิปนี้แสดง Jetpack Compose สำหรับ XR ที่แสดงผลแผงเชิงพื้นที่ UI 2 มิติแบบเนทีฟสำหรับสกอร์บอร์ด การแจ้งเตือน และไฮไลต์

  • นอกจากนี้ Godot ยังแสดงผลการจำลองการแข่งขันฟุตบอลด้วย

การสื่อสารระหว่าง Kotlin และ Godot

  • คุณสามารถดูรายละเอียดวิธีที่เราสื่อสารจาก Kotlin ไปยัง GDScript ได้ที่นี่

  • Godot จึงมีสถาปัตยกรรมปลั๊กอินเพื่อรองรับการดำเนินการนี้

  • คุณเขียนคลาส Kotlin แบบง่ายที่ขยาย GodotPlugin

  • คลาสนี้ทำหน้าที่เป็นอินเทอร์เฟซระหว่าง JVM กับรันไทม์ C++ ของ Godot

  • ด้วยเหตุนี้ การโต้ตอบระหว่างแอปของคุณกับ Godot จึงมีขนาดเล็กมาก

  • สถาปัตยกรรมของ Godot รองรับการสื่อสารแบบ 2 ทางระหว่าง Godot และ Kotlin

  • เมื่อเกิดเหตุการณ์ในเกมใน Godot เช่น การทำประตูหรือการทำฟาวล์ Godot จะเรียกใช้ฟังก์ชัน Kotlin และอัปเดต Compose UI แบบเรียลไทม์ได้โดยตรง

  • จากนั้นเมื่อผู้ใช้โต้ตอบกับมีเดียเพลเยอร์ เช่น เพื่อหยุดชั่วคราวหรือเลือกไฮไลต์ต่างๆ Kotlin จะส่งสัญญาณกลับไปยัง Godot และการดำเนินการของเกมเอนจินที่เกี่ยวข้องจะเกิดขึ้น

  • เมื่อปลั๊กอิน Godot มีการใช้งานข้ามแพลตฟอร์ม ประสิทธิภาพและการจัดการจะขึ้นอยู่กับโดเมน

  • ใช้แนวทางปฏิบัติมาตรฐานที่ดีที่สุดสำหรับแอป Android และแนวทางปฏิบัติมาตรฐานที่ดีที่สุดสำหรับโปรเจ็กต์ Godot

  • ตอนนี้เราจะมาดูสถาปัตยกรรมกัน

  • มาดูวิธีเชื่อมต่อสิ่งนี้เข้ากับ Godot ในแอป Android กัน

  • รันไทม์ของเครื่องมือ Godot จะจัดจำหน่ายผ่าน Maven เป็น AAR

  • การผสานรวมเข้ากับบิลด์จะเป็นไปตามแนวทางปฏิบัติมาตรฐานของ Android

  • มีขั้นตอนสำคัญขั้นตอนหนึ่งที่คุณต้องระวังทันที ซึ่งเราเรียกว่า "กับดักชิ้นงาน"

    androidResources {
         ignoreAssetsPattern = "!.svn:!.git:!.gitignore:!.ds_store:!*.scc:<dir>_*:!CVS:!*~:!android:!export_presets.cfg"
     }
  • ในโปรเจ็กต์ Godot ฉากและไฟล์การกำหนดค่าโปรเจ็กต์จะอยู่ร่วมกัน

  • และเมื่อฝัง Godot ระบบจะเพิ่มโปรเจ็กต์ Godot ลงในโฟลเดอร์เนื้อหาโดยตรง

  • ซึ่งหมายความว่าเมช 3 มิติ พื้นผิวที่บีบอัด และพื้นผิวที่คอมไพล์แล้วทั้งหมดที่นำเข้าจะจัดเก็บเป็นอาร์ติแฟกต์รันไทม์ภายในไฟล์ที่ซ่อนอยู่ซึ่งชื่อว่า .godot

  • โดยค่าเริ่มต้น Android จะตัดโฟลเดอร์นี้ออกเมื่อทำการบิลด์ ดังนั้นจึงละเว้นไดเรกทอรีที่มีจุด

  • ดังนั้นหากคุณซิงค์และสร้างแอปตามปกติ APK จะสร้างขึ้น แต่จะขัดข้องและโหลดเนื่องจากสิ่งเหล่านั้นไม่ได้คอมไพล์ลงใน APK

  • ดังนั้น หากต้องการแก้ไขปัญหานี้ คุณสามารถลบล้างตัวเลือก AAPT ภายใน build.gradle อย่างชัดแจ้ง และอนุญาตให้มีจุดนำหน้าจาก Godot ได้

  • เมื่อทำเช่นนั้นแล้ว คุณจะเรียกใช้ฉาก Godot ได้ 1 ครั้งในเครื่องมือแก้ไข 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 ใน Jetpack Compose

  • เมื่อแพ็กเกจเนื้อหาแล้ว Godot และ Compose จะแสดงผลอย่างไร

  • ไลบรารี Godot มีเมธอด Factory ที่ชื่อว่า 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 เพียงแค่ใช้อินสแตนซ์ปลั๊กอิน

    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 ของคุณจะต้องเหมือนกันทุกประการ จึงไม่มีการแปลงการเขียนแบบ Camel Case หรือ Snake Case โดยอัตโนมัติ

  • ดังนั้น คุณเพียงแค่ต้องตรวจสอบว่าทั้ง 2 วิธีเหมือนกันทุกประการ

  • เมื่อเชื่อมต่อปลั๊กอินแล้ว ส่วนที่สำคัญที่สุดของการพูดคุยนี้คือสิ่งที่ฉันเรียกว่าโมเดลทางความคิด

  • นี่คือจุดที่เกมเอนจินและ Jetpack Compose ทำงานบนสถาปัตยกรรม 2 แบบที่แตกต่างกันโดยพื้นฐาน

  • เกมเอนจินทำงานบนเฟรมทิกอย่างต่อเนื่อง

  • ไม่ว่าคุณจะเปลี่ยนแปลงอะไรหรือไม่ กระบวนการ Tick ของเกมเอนจินจะเกิดขึ้น 60, 90, 100 ครั้งต่อวินาที เพื่อให้การแสดงผลแบบเรียลไทม์ที่สมบูรณ์และราบรื่น

  • และเอนจินอย่าง Godot ก็ใช้รอบ GPU และ CPU ได้อย่างมีประสิทธิภาพ

  • ในทางกลับกัน Jetpack Compose จะจัดการสิ่งต่างๆ แบบเชิงรับและขับเคลื่อนด้วยสถานะ

  • และเมื่อแอปไม่มีการเปลี่ยนแปลง Compose จะทำงานต่อไปโดยใช้ CPU 0%

  • หากต้องการเชื่อมต่อสิ่งเหล่านี้เพื่อเรียกใช้รันไทม์ 2 รายการโดยไม่มีกลยุทธ์ที่ชัดเจน ปรัชญาเหล่านี้ก็มักจะขัดแย้งกัน

  • มาดูกันว่าเราจะแก้ปัญหานี้ได้อย่างไรด้วยสิ่งที่ผมเรียกว่า "การรั่วไหลของเครื่องหมายถูก"

  • นี่คือจุดที่เรามีประสบการณ์การใช้งาน 3 มิติ เช่น ตัวจับเวลาบนสกอร์บอร์ด

  • ในสคริปต์เครื่องมือ มีตัวแปรตัวจับเวลา ti- ที่เปลี่ยนแปลงอย่างต่อเนื่อง

  • และหากคุณส่งต่อตัวแปรนี้ไปยัง Compose โดยไม่ตรวจสอบ เช่น 90 ครั้งต่อวินาที คุณจะเข้าสู่ลูปการจัดองค์ประกอบใหม่นี้ 90 ครั้งต่อวินาที

  • ซึ่งหมายความว่า Compose จะประเมิน Composable ใหม่ วัดเลย์เอาต์ใหม่ และวาด UI Tree ใหม่ 90 ครั้งต่อวินาที

  • และคุณจะเห็นเฟรมดรอป เห็น UI แล็ก และจะพบกับการควบคุมความร้อนอย่างรวดเร็ว

  • ดังนั้น กฎเหล็กคืออย่าปล่อยให้ลูปเครื่องมือต่อเนื่องกำหนดลูป UI แบบโต้ตอบ

  • ในตัวอย่างนี้ แทนที่จะให้ Godot อัปเดตตัวจับเวลาบนสกอร์บอร์ดอย่างต่อเนื่อง ฉันแค่ตรวจสอบว่า Android ดึงข้อมูลนี้ทุกๆ 1 วินาทีเมื่อจำเป็น

    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 จะทำงานอย่างต่อเนื่องในเทรดการแสดงผลของตัวเอง

  • UI ของ 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 จะช่วยให้คุณได้วิวพอร์ต 3 มิติที่ลื่นไหลโดยไม่ส่งผลกระทบต่อระยะเวลาการใช้งานแบตเตอรี่ การช่วยเหลือพิเศษ หรือประสิทธิภาพของแอปที่มาพร้อมเครื่อง

  • ตอนนี้เรามาดูวิธีเพิ่มคอนเทนเนอร์เชิงพื้นที่ลงในโปรเจ็กต์ Godot กัน

  • ก่อนอื่น เรามาเริ่มด้วยการตั้งค่าแอปให้เป็นแบบ Full Space, เปิดใช้คอนเทนเนอร์เชิงพื้นที่พร้อมคำแนะนำขอบเขต แล้วตั้งค่าไลบรารี XR Loader ในไฟล์ Android Manifest

    <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 Shader และคอนเทนเนอร์เชิงมิติพื้นที่ได้ผ่านเครื่องมือแก้ไข

  • และนั่นคือทั้งหมดที่คุณต้องทำเพื่อเปิดใช้คอนเทนเนอร์เชิงพื้นที่ภายใน Godot

  • ตอนนี้คุณได้ทำให้ Godot ทำงานร่วมกับ Android XR, คอนเทนเนอร์เชิงพื้นที่ และพัฒนาทั้งหมดนี้แล้ว แนวทางปฏิบัติแนะนำสำหรับการใช้คอนเทนเนอร์เชิงพื้นที่ รวมถึงแผงเชิงพื้นที่และ JXR เราขอแนะนำให้ดูข้อมูลเพิ่มเติมในเว็บไซต์สำหรับนักพัฒนาแอป

  • เราเพิ่งพูดถึงตัวอย่างวิธีใช้คอนเทนเนอร์เชิงมิติพื้นที่ภายใน Godot Engine และ Android XR และเรามั่นใจว่าหลายคนคงอยากฟังจากนักพัฒนาแอปที่กำลังทำงานเกี่ยวกับคอนเทนเนอร์เชิงมิติพื้นที่สำหรับประสบการณ์การใช้งาน Android XR ของตน

  • ต่อไป ผมขอเชิญ Josh และ Rodrigo จาก NBA ซึ่งเป็นหนึ่งในพาร์ทเนอร์กลุ่มแรกๆ ที่ใช้คอนเทนเนอร์เชิงพื้นที่ภายในแอปพลิเคชันของตน

ภาพรวมของ NBA Court View

  • ขอบคุณ Luke

  • สวัสดีทุกคน

  • ผมชื่อ Josh เป็นนักพัฒนาแอป Android ของ NBA

  • วันนี้ฉันอยู่กับ Rodrigo Sanchez เพื่อนร่วมงานของฉันซึ่งเป็นนักพัฒนาซอฟต์แวร์ Unity ที่ Beyond Sports

  • และวันนี้เราอยากจะแสดงให้คุณเห็นว่าเราใช้ Unity เป็นไลบรารีเพื่อสร้าง Court View สำหรับแอป NBA อย่างไร

  • NBA พยายามมอบประสบการณ์ใหม่ๆ ให้กับแฟนๆ อยู่เสมอ

  • เราจึงร่วมมือกับ Google และ Beyond Sports เพื่อเปิดตัว Court View

  • Court View นำเสนอการจำลองการแข่งขัน NBA แบบ 3 มิติ ซึ่งช่วยให้สมาชิก League Pass สามารถรับชมจากมุมมองใหม่ๆ ที่ไม่สามารถทำได้ด้วยวิดีโอการออกอากาศปกติ

  • ปรับขนาดและหมุนสนามใหม่ โดยวางไว้ที่ใดก็ได้ในพื้นที่ของคุณ

  • คุณสามารถเคลื่อนที่ไปรอบๆ และดูการแข่งขันจากมุมมองที่ไม่เหมือนใคร

  • ในระหว่างนี้ คุณสามารถใช้แอป NBA ได้ตามปกติ ตั้งแต่การดูสถิติที่ซิงค์ไปจนถึงการดูการถ่ายทอดสด

  • การตั้งค่าทั้งหมดนี้เป็นอย่างไรบ้าง

  • มุมมองสนามจะแสดงผลใน Unity และแอป Android ของเราจะใช้ Unity เป็นไลบรารี

  • จากนั้นเราจะสร้าง Android Archive รายการเดียวที่โฮสต์ใน Maven Central ซึ่งแอป NBA สามารถดึงเข้ามาเป็นทรัพยากร Dependency รายการเดียวได้

  • ซึ่งช่วยให้เราแบ่งงานระหว่างฝั่ง Android และฝั่ง Unity ได้

  • เราแต่ละคนจึงสามารถมุ่งเน้นไปที่สิ่งที่เรารู้ดีที่สุด และทำงานควบคู่กันไปเพื่อเร่งการพัฒนาโดยรวม

  • ตัวอย่างเช่น ขณะที่ทีมของเราทำงานเกี่ยวกับประสบการณ์การใช้งาน Full Space ใน Compose ทีม Unity ก็สามารถมุ่งเน้นไปที่การปรับขนาดและเลย์เอาต์ของสนาม 3 มิติได้

  • ต่อไปฉันจะส่งต่อให้ Rodrigo พูดคุยเพิ่มเติมเกี่ยวกับโปรเจ็กต์ Unity และวิธีที่พวกเขาทำงานร่วมกับ Unity as a Library

การผสานรวม Unity เป็นไลบรารี

  • ขอบคุณ Josh

  • Beyond Sports ได้ทำงานร่วมกับ NBA มาหลายปีเพื่อนำเสนอการแสดงภาพ 3 มิติด้วยข้อมูลจริงจากการแข่งขัน

  • ในโปรเจ็กต์นี้ เราได้ปรับการจำลอง 3 มิติให้เข้ากันได้กับ Android XR

  • การจำลองของเราสร้างขึ้นบนเครื่องมือ Unity ด้วย C#

  • เราส่งออกการจำลองไปยัง Android โดยใช้ Unity เป็นไลบรารี และสร้างเลเยอร์การสื่อสารไว้ข้างๆ โดยใช้ Kotlin

  • ก่อนที่จะรวม 2 โมดูลนี้เข้าด้วยกันเป็นทรัพยากร Dependency ของ Maven รายการเดียวเพื่อให้ NBA รวมไว้ในแอปพลิเคชันของตน

  • บนหน้าจอ คุณจะเห็นวิดีโอสั้นๆ ของการจำลองแบบเดียวกับที่ Josh แสดงให้เห็นก่อนหน้านี้ ซึ่งทำงานบนเครื่องมือแก้ไข

  • เราใช้แพ็กเกจส่วนขยาย Android XR สำหรับ Unity เพื่อให้บรรลุเป้าหมายนี้

  • เราจะแนะนำขั้นตอนให้คุณ

  • ดังนั้นก่อนที่จะสร้างใน Unity โปรดตรวจสอบว่าคุณได้เลือกการตั้งค่าและปลั๊กอินที่ถูกต้องแล้ว

  • ดาวน์โหลดแพ็กเกจ XR Plug-in Management ของ Unity และแพ็กเกจ Android XR Extensions for Unity ของ Google

  • กำหนดค่าในส่วนการตั้งค่า XR Plug-in Management เพื่อใช้ผู้ให้บริการ OpenXR Android XR Extensions

  • สำหรับ Use Case ของเรา เราต้องการใช้คอนเทนเนอร์เชิงพื้นที่ จึงเปิดใช้ฟีเจอร์ดังกล่าวและปิดใช้ XR แบบสมจริงจากการตั้งค่าการจัดการเซสชัน

  • เราไม่ได้ใช้แพ็กเกจอินพุต XR เนื่องจากอินพุตทั้งหมดได้รับการจัดการแบบเนทีฟนอก Unity

  • ถัดไป ในการตั้งค่าโปรแกรมเล่น คุณจะต้องตั้งค่าโปรเจ็กต์ให้ใช้ระดับ API 24 ขึ้นไปเป็นอย่างต่ำ และต้องตั้งค่าจุดแรกเข้าของแอปพลิเคชันเป็น Game Activity ที่ใหม่กว่า

  • โปรดทราบว่าคุณต้องไม่เลือก "กิจกรรม"

  • เราสนใจเฉพาะการสร้างสำหรับ ARM64 เท่านั้น จึงเลือกสถาปัตยกรรมเป้าหมายดังกล่าว และในส่วนความละเอียด เราได้เปิดใช้ "เรียกใช้ในเบื้องหลัง" เพื่อให้คุณทำงานหลายอย่างพร้อมกันกับแอปอื่นๆ ได้ และให้การจำลอง 3 มิติทำงานอยู่เบื้องหลัง

  • นอกจากนี้ เรายังเปิดใช้ Resizeable Activity เนื่องจากจำเป็นสำหรับการแสดงผลกล่องโต้ตอบ เช่น คำขอสิทธิ์ของระบบ

  • สุดท้ายนี้ เราขอแนะนำให้แสดงผลแอป 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")
    }
    }
  • ในโปรเจ็กต์นี้ เรามีโมดูล 4 โมดูล ได้แก่ โมดูล APK หลักสำหรับทดสอบเป็นตัวยึดตำแหน่งจำลองสำหรับแอปพลิเคชัน Wrapper ของ 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) }
    }
    }
  • กิจกรรมทั้ง 2 รายการมีสิทธิ์เข้าถึงบริดจ์กิจกรรมและเมธอดของยูทิลิตีภายใน

  • บริดจ์มีการอ้างอิงถึงบริการที่ติดตั้งใช้งานอินเทอร์เฟซ 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) }
  • แผนภาพนี้แสดงให้เห็นวิธีการทำงานของการสื่อสารระหว่างกระบวนการได้ดียิ่งขึ้น

  • อย่างที่คุณเห็น การใช้บริดจ์ระหว่างกิจกรรมช่วยให้เราจัดการ Callback ที่กล่าวถึงได้

  • เราตั้งค่า StateFlows ที่อัปเดตจาก Unity เพื่อตรวจสอบค่าต่างๆ เช่น สถานะการตรวจสอบสิทธิ์หรือการประทับเวลาสถานะปัจจุบันของการแข่งขัน

  • นอกจากนี้ เรายังจัดการการเข้ารหัสข้อมูลเข้าสู่ระบบ และส่งต่อข้อผิดพลาดจาก Unity เพื่อให้ NBA สามารถแก้ไขข้อบกพร่องได้ง่ายขึ้นและตัดสินใจว่าจะจัดการข้อผิดพลาดเหล่านั้นอย่างไรให้ดีที่สุดในระดับแอปพลิเคชัน

  • เรามีข้อมูลเพียงเท่านี้

  • ตอนนี้ฉันจะส่งต่อเวทีให้ Josh เพื่อพูดคุยเพิ่มเติมเกี่ยวกับวิธีที่พวกเขาผสานรวมแพ็กเกจ Court View Unity เข้ากับแอป NBA

  • เราใช้ทรัพยากร Dependency ของ Gradle เหล่านี้เพื่อผสานรวม Unity เป็นไลบรารีกับแอป Jetpack Compose ของเรา ซึ่งได้แก่ XR Runtime เพื่อเข้าถึงเซสชัน 3 มิติ, 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")
    }
  • ต่อไป เรามาดูวิธีแสดงผลมุมมองสนามใน 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 เราจะนำคุณเข้าสู่ Full Space

  • ซึ่งจำเป็นสำหรับการแสดงแผงเชิงพื้นที่ที่เก็บ Unity ไว้

  • จากนั้นเราจะส่งข้อมูลเกมผ่าน AIDL เพื่อเปิด Court View สำหรับเกมที่เฉพาะเจาะจง

  • จากนั้น Unity จะเริ่มทำงาน

  • ไฟล์ Android ของเราจะส่งข้อมูลนี้เป็น Intent ไปยังกิจกรรม Unity และเราจะรวมข้อมูลนี้ไว้ใน SpatialActivityPanelController

  • จากนั้นเราจะส่งตัวควบคุมนั้นไปยัง SpatialActivityPanel

  • แผงนี้เป็นแผงที่แสดงมุมมองสนาม และแชร์ในพื้นที่ย่อยของเรากับแผงเชิงพื้นที่หลัก

  • ซึ่งช่วยให้เราควบคุมทั้ง 2 อย่างพร้อมกันได้โดยไม่มีปัญหา

  • ง่ายๆ แค่นี้เอง

  • เราจะส่งกลับไปให้ Luke

สรุปและแหล่งข้อมูล

  • ขอบคุณมากครับ

  • อย่างที่คุณเห็น NBA สามารถเร่งการพัฒนา Court View ได้โดยแยกการพัฒนา Unity และ Jetpack Compose ออกจากกัน

  • ดูข้อมูลเพิ่มเติมเกี่ยวกับการเริ่มต้นใช้งานคอนเทนเนอร์เชิงพื้นที่และการใช้คอนเทนเนอร์เชิงพื้นที่ภายในแอป Android XR ของคุณเองได้ที่เอกสารประกอบสำหรับนักพัฒนาซอฟต์แวร์ใน developer.android.com/develop/xr

  • ขอขอบคุณเป็นอย่างยิ่งที่มาร่วมเซสชันของเราในวันนี้ และเราจะตั้งตารอดูผลงานที่คุณสร้างขึ้น

รหัส

ข้อมูลโค้ด

ข้อมูลโค้ดสคริปต์ Kotlin ที่ 09:30 ใน "การสื่อสารระหว่าง Kotlin กับ 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 ใน "การแสดงผล Godot ใน 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 ใน "การแสดงผล Godot ใน 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 ใน "การแสดงผล Godot ใน 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 ใน "การแสดงผล Godot ใน 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 ที่ 20:41 ใน "การผสานรวม Unity เป็นไลบรารี"
// 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 ใน "การผสานรวม Unity เป็นไลบรารี"
public class NativeInterfaceListener : AndroidJavaProxy {
[UsedImplicitly]
public void startMatch(string matchID) {
   UnityThreadHelper.InvokeOnMain(() => _internalAPI.StartMatch(matchID));
}
}
ข้อมูลโค้ด Kotlin ที่ 22:54 ใน "การผสานรวม Unity เป็นไลบรารี"
fun onStartMatchClickedInternal() {
   if (isServiceBound) {
       AIDLBridgeService.startMatch("match_id")
   } else {
       delayedCallbacksQueue.add(action)
   }
}
ข้อมูลโค้ด C# ที่เวลา 22:57 ใน "การผสานรวม Unity เป็นไลบรารี"
// After bootload and initialization

using AndroidJavaClass nativeBridge = new("com.company.UnityActivity");
using AndroidJavaObject instance = nativeBridge.GetStatic<AndroidJavaObject>("instance");
instance.Call("onUnityReady");
ข้อมูลโค้ด Kotlin ที่นาที 23:06 ใน "การผสานรวม Unity เป็นไลบรารี"
@Suppress("unused") //  Unity implicit call
fun onUnityReady() {
   bridge.activityBridgeServiceInternal?.onUnityReady()
}

delayedCallbacksQueue.forEach { it(activityBridgeService) }
ข้อมูลโค้ด Kotlin Script ที่นาที 23:50 ใน "การผสานรวม Unity เป็นไลบรารี"
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 ใน "การผสานรวม Unity เป็นไลบรารี"
// 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)
        }
    }
}