วิศวกร Android ระดับอาวุโส
ดูวิธีผสานรวมเกมเอนจิน 3 มิติ (Godot และ Unity) กับ Jetpack Compose ไว้ในฐานของโค้ดเดียวสำหรับอุปกรณ์เคลื่อนที่และ Android XR เราจะสำรวจการฝังฉาก 3 มิติลงในคอนเทนเนอร์เชิงพื้นที่ การตั้งค่าการสื่อสารระหว่าง Kotlin กับเครื่องมือ พร้อมทั้งเจาะลึกวิธีที่ NBA สร้างประสบการณ์การรับชมบาสเกตบอลแบบสดบน Android XR
GodotPlugin จะจัดการการสื่อสารแบบ 2 ทิศทางกับ Kotlinข้อความถอดเสียง
สวัสดีทุกคน
ฉันชื่อ 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 เฟรมต่อวินาทีได้
โดยจะทำการคำนวณทางฟิสิกส์, ตัวปรับเฉดสีที่ซับซ้อน, ภาพเคลื่อนไหวแบบโครงกระดูก, เสียงรอบทิศทาง และทั้งหมดนี้มีประสิทธิภาพที่น่าทึ่ง
แล้วจะเกิดอะไรขึ้นหากคุณต้องการใช้ประโยชน์จากทั้ง 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 ไปยัง 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 และ 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 ซึ่งเป็นหนึ่งในพาร์ทเนอร์กลุ่มแรกๆ ที่ใช้คอนเทนเนอร์เชิงพื้นที่ภายในแอปพลิเคชันของตน
ขอบคุณ 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
ขอบคุณ 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
ขอขอบคุณเป็นอย่างยิ่งที่มาร่วมเซสชันของเราในวันนี้ และเราจะตั้งตารอดูผลงานที่คุณสร้างขึ้น
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)
}
}
}