Android 스태프 엔지니어
Jetpack Compose를 사용하여 3D 게임 엔진 (Godot 및 Unity)을 모바일 및 Android XR용 단일 코드베이스에 통합하는 방법을 알아보세요. 3D 장면을 공간 컨테이너에 삽입하고 Kotlin과 엔진 간의 통신을 설정하는 방법을 살펴봅니다. NBA가 Android XR에서 실시간 농구 경험을 구축한 방법을 독점적으로 살펴봅니다.
GodotPlugin 서브클래스가 Kotlin과의 양방향 통신을 처리합니다.스크립트
안녕하세요.
제 이름은 루크 홉킨스입니다.
Android XR팀의 개발자 관계 엔지니어입니다.
오늘 강연에서는 Godot, Unity와 같은 게임 엔진을 Android 프로젝트에 삽입하는 방법을 설명하겠습니다.
이후에는 NBA와 Beyond Sports의 조시와 로드리고가 참여하여 NBA 애플리케이션에 Unity를 라이브러리로 구현한 방법을 설명할 예정입니다.
이 세션에서는 Godot을 Jetpack Compose 애플리케이션에 통합하는 방법을 자세히 알아보고, 조시와 로드리고가 NBA 애플리케이션을 살펴보겠습니다.
Android XR은 Android 생태계의 확장으로, 다양한 폼 팩터를 기반으로 확장되어 익숙하고 좋아하는 경험을 새로운 차원으로 가져옵니다.
기존 XR 및 Android 앱을 이 새로운 공간으로 직관적으로 가져올 수 있도록 했으며, 현재 개발에 사용 중인 기존 Android 기술을 활용하고 있습니다.
Android XR은 다양한 기기를 위해 설계된 통합 플랫폼입니다.
Android XR 기기는 다양한 유형의 환경을 지원합니다.
오디오 및 디스플레이 글라스를 위한 증강 환경과 XR 헤드셋 및 유선 XR 글라스를 위한 몰입형 환경이 있습니다.
오늘은 유선 XR 글래스 및 XR 헤드셋을 위한 몰입형 환경을 빌드하는 방법을 살펴보겠습니다.
몰입형 환경은 Jetpack XR SDK를 사용하거나 실시간 엔진과 함께 OpenXR를 사용하여 만들 수 있습니다.
Google은 Unity, Godot, Unreal을 지원하므로 어떤 도구를 사용하든 Android용 몰입형 환경을 빌드할 수 있습니다.
이 강연에서는 Jetpack의 SceneCore API를 Godot 및 새로운 OpenXR 확장 프로그램과 함께 활용하여 이를 실현하는 방법을 설명하겠습니다.
여기에서 Godot가 Jetpack의 공간 패널과 나란히 실행되는 것을 볼 수 있습니다.
Godot가 공간 콘텐츠로 렌더링되어 공간 컨테이너 내에서 2D Compose UI와 함께 3D 볼륨을 제공하는 방식을 확인하세요.
이 접근 방식을 사용하면 개발자는 원하는 실시간 엔진을 사용하여 물리 시뮬레이션과 맞춤 셰이더를 실행하는 동시에 애플리케이션의 나머지 부분에 네이티브 Android 개발 관행을 적용할 수 있습니다.
Android XR과 XREAL AURA를 사용하면 여러분도 이러한 경험을 만들 수 있습니다.
이 세션에서는 Godot과 Android 스튜디오를 사용하여 이러한 환경을 빌드하는 방법을 알아보고, NBA와 Beyond Sports의 파트너가 Unity를 라이브러리로 사용하여 이러한 환경을 빌드한 방법을 들어봅니다.
과거에는 OpenXR로 XR 환경을 빌드한다는 것은 모든 픽셀을 렌더링하는 환경을 빌드한다는 의미였습니다.
즉, 개발자가 두 번째 활동을 표시해야 하는 경우 두 활동을 한 번에 표시할 수 없습니다.
이 문제를 해결하기 위해 OpenXR 실무 그룹인 Khronos는 여러 업계 리더와 협력하여 OpenXR 공간 컨테이너 확장 프로그램을 출시했습니다.
공간 컨테이너는 상자 안의 OpenXR라고 생각하면 됩니다.
엔진이 전체 스카이박스를 소유하는 대신 Jetpack의 XR 공간 패널, 다른 공간 컨테이너, 표준 Android 활동과 함께 사용할 수 있는 시스템 관리 3D 볼륨이 부여됩니다.
몰입형 게임을 실행하는 동안 두 개의 공간 패널을 한 번에 표시하여 Android XR을 사용한다고 가정해 보겠습니다.
엔진에서 모든 항목을 렌더링하므로 이러한 사용 사례에서 Godot 편집기와 공간 컨테이너를 동시에 실행할 수 있습니다.
Spatial API 수준 4부터 Google은 Android XR에 공간 컨테이너를 구현했습니다.
공간 컨테이너는 두 가지 중요한 디스플레이 모드를 지원합니다.
첫 번째는 제한 모드입니다.
이렇게 하면 여러 기본 제공 회피 기능이 있는 정의된 3D 볼륨이 제공됩니다.
사용자는 공간 컨테이너를 잡고, 회전하고, 배치하고, 잡고, 최소화할 수 있으며, 스테레오 깊이 투사를 지원하여 컨테이너의 가장자리를 클리핑하면서도 매직 윈도우나 3D 팝아웃 효과를 제공합니다.
두 번째는 무제한 모드입니다.
사용자가 완전 몰입을 원하는 경우 앱은 제한 모드에서 전체 공간 모드로의 원활한 전환을 요청할 수 있으며, 이후 제한 모드로 다시 돌아갈 수도 있습니다.
공간 컨테이너가 추가되면서 Android XR은 새롭고 광범위한 아이디어를 실현할 수 있는 가능성을 열어줍니다.
Android UI를 게임에 가져오거나 애플리케이션에서 3D 콘텐츠를 표시하기 위해 맞춤 셰이더를 만들고 싶다고 가정해 보겠습니다.
공간 컨테이너가 도입되기 전에는 Compose와 게임 엔진을 동시에 실행할 수 없었습니다.
실시간 엔진의 애플리케이션 개발을 살펴보면, 우리는 두 가지 다른 패러다임 속에서 살아왔습니다.
네이티브 Android 개발자의 경우 애플리케이션 런타임은 Jetpack Compose를 중심으로 빌드됩니다.
빠르고 반응성이 뛰어난 UI, 배터리 효율성, 동적 레이아웃, 시스템 접근성, 원활한 텍스트 입력을 위해 설계되었습니다.
아무 일도 일어나지 않을 때는 유휴 상태가 되며 예측 가능합니다.
게임 엔진 개발자의 경우 Godot 및 Unity와 같은 도구가 빛을 발하는 실시간 렌더링이 도메인입니다.
사용자 상호작용을 기다리기만 하는 것이 아닙니다.
연속 틱 및 렌더링 루프를 실행합니다.
따라서 초당 60, 90, 100프레임 속도로 설정할 수 있습니다.
물리 계산, 복잡한 셰이더, 스켈레톤 애니메이션, 서라운드 오디오를 모두 놀라운 성능으로 처리합니다.
그렇다면 두 가지 장점을 모두 누리고 싶다면 어떻게 해야 할까요?
이전에는 개발자가 이 두 가지 접근 방식 중 하나를 구현해야 했습니다.
옵션 A는 Godot 또는 Unity와 같은 실제 엔진 내에서 전체 애플리케이션을 다시 작성하는 것입니다.
기존 코드베이스를 버린다는 것은 네이티브 Compose UI를 많이 잃고, 기본 텍스트와 탐색을 다시 구현하고, 텍스트 음성 변환과 같은 Android API를 둘러싼 래퍼를 작성하는 것을 의미합니다.
옵션 B는 네이티브 앱 내에서 원시 Vulkan 및 OpenXR API를 사용하여 저수준 3D 그래픽 렌더링 파이프라인을 처음부터 빌드하는 것입니다.
제품 기능을 제공하는 대신 자체 독점 엔진을 구축하는 셈입니다.
따라서 오늘은 Godot를 라이브러리로, Unity를 라이브러리로 최대한 활용하는 방법을 보여드리겠습니다.
이제 공간 컨테이너를 추가하여 두 가지 장점을 모두 누릴 수 있습니다.
Jetpack 애플리케이션의 라이브러리 내에 실시간 엔진을 직접 삽입하면 이를 달성할 수 있습니다.
프로덕션 Android 앱 구조, 비즈니스 로직, 애플리케이션 내 3D 공간 콘텐츠의 원활한 렌더링을 유지합니다.
즉, 모든 폼 팩터에 하나의 APK를 사용할 수 있습니다.
3D 콘텐츠를 표시하지 않으려는 경우 활동이나 프래그먼트에 라이브러리를 로드하지 않고 코드베이스를 명확하게 유지할 수 있으며, 이를 통해 많은 기기를 지원할 수 있습니다.
철학은 간단합니다. 애플리케이션을 분리하고 실시간 엔진을 라이브러리로 만드는 것입니다.
Android 앱과 엔진 중 하나를 선택하는 대신 엔진을 삽입된 소프트웨어 라이브러리(AAR 종속 항목이라고도 함)로 취급합니다.
다른 라이브러리와 마찬가지로 Gradle 빌드로 직접 가져올 수 있습니다.
네이티브 Android 코드는 3D 구성요소를 호스팅하는 애플리케이션의 핵심으로 유지됩니다.
Jetpack Compose XR은 네이티브 접근성 유동 UI를 렌더링하고 앱 상태를 관리하는 등 가장 잘하는 작업을 처리합니다.
3D 요소가 필요한 경우 앱은 게임 엔진 자체에서 렌더링하는 삽입된 렌더링 뷰를 호스팅합니다.
오늘은 Godot에서 이 기능이 내부적으로 어떻게 작동하는지 자세히 살펴보겠습니다.
Kotlin과 GDScript 간의 양방향 통신을 삽입하고 설정하는 메커니즘부터 시작하겠습니다.
그런 다음 새로운 멀티태스킹 및 공간 레이아웃을 구현하는 새로운 OpenXR 확장 프로그램인 Spatial Container를 살펴보겠습니다.
앞서 보여드린 데모를 간단히 살펴보겠습니다.
Godot와 Android 스튜디오를 사용하여 만들었습니다.
이 클립에서는 XR용 Jetpack Compose가 스코어보드, 알림, 하이라이트를 위한 네이티브 2D UI 공간 패널을 렌더링하는 모습을 보여줍니다.
게다가 Godot은 축구 경기 시뮬레이션을 렌더링하고 있습니다.
여기에서 Kotlin에서 GDScript로 통신하는 방법을 자세히 확인할 수 있습니다.
이를 위해 Godot는 플러그인 아키텍처를 제공합니다.
GodotPlugin를 확장하는 가벼운 Kotlin 클래스를 작성합니다.
이 클래스는 JVM과 Godot의 C++ 런타임 간의 인터페이스 역할을 합니다.
따라서 앱과 Godot 간의 상호작용은 매우 가볍습니다.
Godot의 아키텍처는 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 프로젝트가 애셋 폴더 내에 직접 추가됩니다.
즉, 가져온 모든 3D 메시, 압축된 텍스처, 컴파일된 텍스처가 모두 .godot라는 숨은 파일 내에 런타임 아티팩트로 저장됩니다.
기본적으로 Android는 빌드 시 이 폴더를 제거하므로 점이 있는 디렉터리는 무시됩니다.
따라서 앱을 정상적으로 동기화하고 빌드하면 APK가 빌드되지만 이러한 항목이 APK로 컴파일되지 않았기 때문에 비정상 종료되고 로드됩니다.
이 문제를 해결하려면 build.gradle 내에서 AAPT 옵션을 명시적으로 재정의하고 Godot에서 선행 점을 허용하면 됩니다.
이렇게 하면 Godot 편집기에서 Godot 장면을 한 번 실행할 수 있으며, 여기에서 Android 스튜디오의 다른 모든 항목을 빌드할 수 있습니다.
공간 컨테이너와 최신 OpenXR를 지원하려면 Godot 4.8 이상, Godot 공급업체 확장 프로그램 5.2, Khronos OpenXR 로더 1.1.63을 사용하는 것이 좋습니다.
dependencies {
// Godot 4.8 Android Library + Godot OpenXR Vendors plugin + Khronos OpenXR Loader
implementation("org.godotengine:godot:4.8.0.dev-SNAPSHOT")
implementation("org.godotengine:godot-openxr-vendors-androidxr:5.1.0-stable")
implementation("org.khronos.openxr:openxr_loader_for_android:1.1.63")
// Jetpack XR SDK (Compose for XR, SceneCore, Runtime)
implementation("androidx.xr.compose:compose:1.0.0-beta02")
implementation("androidx.xr.scenecore:scenecore:1.0.0")
implementation("androidx.xr.runtime:runtime:1.0.0")애셋이 패키징되면 Godot과 Compose는 실제로 어떻게 렌더링할까요?
Godot 라이브러리는 onInitRenderView라는 팩토리 메서드를 제공합니다.
@Composable
fun GodotView(godotHost: GodotHost, modifier: Modifier = Modifier) {
// 1. Initialize Godot's native render surface
val renderView = remember(godotHost) {
godotHost.godot!!.onInitRenderView(godotHost)
}
// 2. Host the SurfaceView inside Jetpack Compose
AndroidView(
modifier = modifier,
factory = { renderView }
)
}그러면 Godot가 렌더링하는 SurfaceView가 반환됩니다.
따라서 이를 구성 가능한 함수로 래핑하면 이제 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과 통신할 수 있도록, 예를 들어 골이 발생하면 플러그인 인스턴스를 사용하여 Godot 스크립트에서 직접 Kotlin 함수를 호출할 수 있습니다.
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 스크립트에서 직접 함수를 호출할 수 있습니다.
이 기능을 사용할 때 기억해야 할 한 가지 간단한 팁은 UsedByGodot에는 정확한 문자열 일치가 필요하다는 것입니다.
따라서 Godot 스크립트가 onMatchEvent를 호출하는 경우 Kotlin 이름이 정확히 동일해야 하므로 자동 카멜 표기법이나 스네이크 표기법 변환이 없습니다.
따라서 두 메서드가 정확히 동일한지 확인하면 됩니다.
플러그인이 연결된 상태에서 이 강연의 가장 중요한 부분은 제가 정신 모델이라고 부르는 것입니다.
이 부분에서 게임 엔진과 Jetpack Compose는 근본적으로 상반되는 두 가지 아키텍처를 기반으로 작동합니다.
게임 엔진은 연속 프레임 틱에서 작동합니다.
변경사항이 있든 없든 게임 엔진 틱 프로세스는 초당 60, 90, 100회 발생하여 풍부하고 부드러운 실시간 렌더링을 제공합니다.
Godot과 같은 엔진은 GPU 및 CPU 주기를 효과적으로 사용합니다.
반면 Jetpack Compose는 반응형으로 처리하며 상태 기반입니다.
앱이 변경되지 않으면 Compose는 말 그대로 계속 진행되며 CPU를 0만큼 사용합니다.
명확한 전략 없이 두 런타임을 실행하기 위해 이들을 연결하려고 하면 이러한 철학이 충돌하는 경향이 있습니다.
그럼 이 문제를 어떻게 해결할 수 있는지 살펴보겠습니다. 저는 이를'틱 누출'이라고 부릅니다.
여기에는 3D 환경이 있습니다. 스코어보드 타이머라고 가정해 보겠습니다.
엔진 스크립트에는 지속적으로 변경되는 타이머 변수가 있습니다.
이 변수를 Compose에 무작정 전달하면(예: 초당 90회) 초당 90회 이 재구성 루프에 들어가게 됩니다.
즉, Compose는 컴포저블을 다시 평가하고 레이아웃을 다시 측정하며 UI 트리를 초당 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 UI는 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에서 개별 시맨틱스를 유지하면 배터리 수명, 접근성 또는 네이티브 앱 성능을 저해하지 않으면서 매우 부드러운 3D 표시 영역을 얻을 수 있습니다.
이제 Godot 프로젝트에 Spatial Container를 추가하는 방법을 살펴보겠습니다.
먼저 앱을 전체 공간으로 설정하고, 경계 힌트가 있는 공간 컨테이너를 사용 설정한 다음, Android 매니페스트 파일에서 XR 로더 라이브러리를 설정합니다.
<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 셰이더, 공간 컨테이너를 사용 설정할 수 있습니다.
Godot에서 Spatial Containers를 사용 설정하기 위해 해야 할 일은 이게 전부입니다.
이제 Godot을 Android XR, 공간 컨테이너와 함께 사용하고 이 모든 것을 개발했으므로 공간 패널 및 JXR과 함께 공간 컨테이너를 사용하는 권장사항을 개발자 웹사이트에서 자세히 알아보세요.
Godot Engine과 Android XR에서 공간 컨테이너를 사용하는 방법의 예를 살펴보았습니다. Android XR 환경을 위해 공간 컨테이너를 적극적으로 개발하고 있는 개발자의 이야기를 듣고 싶으신 분들이 많을 것입니다.
다음으로, NBA의 조시와 로드리고를 초대하겠습니다. NBA는 애플리케이션 내에서 Spatial Containers를 사용하는 최초의 파트너 중 하나입니다.
감사합니다.
안녕하세요.
제 이름은 조시이고 NBA의 Android 개발자입니다.
오늘 Beyond Sports의 Unity 소프트웨어 개발자인 동료 로드리고 산체스와 함께 나왔습니다.
오늘은 Unity as a Library를 사용하여 NBA 앱의 Court View를 만든 방법을 보여드리겠습니다.
NBA는 항상 팬들에게 새로운 경험을 제공하기 위해 노력하고 있습니다.
이에 따라 Google 및 Beyond Sports와 협력하여 코트 뷰를 선보이게 되었습니다.
코트 뷰는 NBA 경기를 3D로 재현하여 리그 패스 구독자가 일반 중계 영상으로는 불가능한 새로운 시점에서 경기를 시청할 수 있도록 합니다.
코트의 크기를 조절하고 회전하여 공간의 원하는 위치에 배치합니다.
주변을 돌아다니며 독특한 각도에서 경기를 시청하세요.
이때 동기화 통계 보기부터 라이브 방송 시청까지 NBA 앱을 평소처럼 사용할 수 있습니다.
이 모든 것이 어떻게 설정되나요?
코트 뷰는 Unity에서 렌더링되며 Unity를 라이브러리로 사용하는 Android 앱에서 사용됩니다.
그런 다음 Maven Central에서 호스팅되는 단일 Android 보관 파일을 생성하여 NBA 앱이 단일 종속 항목으로 가져올 수 있도록 합니다.
덕분에 Android 측과 Unity 측 간에 작업을 분할할 수 있었습니다.
따라서 각자 가장 잘 아는 것에 집중하고 동시에 작업하여 전반적인 개발 속도를 높일 수 있습니다.
예를 들어 저희 팀이 Compose에서 전체 공간 환경을 작업하는 동안 Unity팀은 3D 코트의 확장과 레이아웃에 집중할 수 있었습니다.
이제 로드리고가 Unity 프로젝트와 Unity를 라이브러리로 사용한 방법에 대해 자세히 설명하겠습니다.
조쉬님, 감사합니다.
Beyond Sports는 수년 동안 NBA와 협력하여 경기에서 얻은 실제 데이터를 3D 시각화로 표현해 왔습니다.
이 프로젝트를 통해 3D 시뮬레이션을 Android XR과 호환되도록 조정했습니다.
시뮬레이션은 C#을 사용하는 Unity 엔진을 기반으로 빌드됩니다.
시뮬레이션을 Unity as a Library를 사용하여 Android로 내보내고, Kotlin을 사용하여 그 옆에 통신 레이어를 빌드했습니다.
NBA가 애플리케이션에 포함할 수 있도록 이 두 모듈을 단일 Maven 종속 항목으로 번들로 묶기 전에,
화면에는 조시가 이전에 보여준 것과 동일한 시뮬레이션의 짧은 동영상이 표시되며, 편집기에서 실행됩니다.
이를 위해 Unity용 Android XR 확장 프로그램 패키지를 사용합니다.
절차를 안내해 드리겠습니다.
따라서 Unity에서 빌드하기 전에 올바른 설정과 플러그인을 선택했는지 확인하세요.
Unity의 XR 플러그인 관리 패키지와 Unity용 Google Android XR 확장 프로그램 패키지를 가져옵니다.
XR 플러그인 관리 설정에서 OpenXR Android XR Extensions 제공자를 사용하도록 구성합니다.
Google의 사용 사례에서는 Spatial Containers를 사용하고 싶었기 때문에 이 기능을 사용 설정하고 세션 관리 설정에서 몰입형 XR을 사용 중지했습니다.
모든 입력은 Unity 외부에서 기본적으로 처리되므로 XR 입력 패키지를 사용하지 않았습니다.
다음으로 플레이어 설정에서 프로젝트가 최소 API 수준 24 이상을 사용하도록 설정하고 애플리케이션 진입점을 최신 Game Activity로 설정해야 합니다.
활동은 선택 해제된 상태로 두어야 합니다.
ARM64용으로만 빌드할 것이므로 해당 대상 아키텍처를 선택하고, 해상도 섹션에서 'Run in Background'를 사용 설정하여 다른 앱으로 멀티태스킹하면서 3D 시뮬레이션을 계속 실행할 수 있도록 합니다.
또한 시스템 권한 요청과 같은 대화상자를 렌더링하는 데 필요하므로 크기 조절 가능한 활동을 사용 설정합니다.
마지막으로 Android XR 앱은 범용 렌더 파이프라인과 Vulkan Graphics API를 사용하여 렌더링하는 것이 좋습니다.
URP 애셋에서 HDR 및 후처리를 사용 중지해야 합니다.
이러한 단계를 모두 완료하면 빌드를 만들 준비가 된 것입니다.
빌드 창으로 이동합니다. Unity 6.1 이상을 사용하는 경우 Android XR 빌드 프로필을 선택할 수 있습니다. 이 프로필을 사용하면 Android 모바일용으로도 빌드하는 경우 설정을 분리할 수 있습니다.
빌드하기 전에 내보내기 프로젝트를 사용 설정해야 합니다.
Unity에서 빌드가 완료되면 Android 스튜디오에서 어셈블하고 다른 Unity APK와 마찬가지로 기기에서 실행할 수 있는 작동하는 Android 프로젝트가 생성됩니다.
Android 스튜디오에서 내보낸 빌드를 다른 애플리케이션에서 사용할 수 있는 라이브러리로 변환합니다.
// 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")
}
}이 프로젝트에는 네 가지 모듈이 있습니다. NBA의 래핑 애플리케이션을 위한 모의 자리표시자로 테스트할 기본 APK 모듈, 내보낸 빌드를 보유하는 Unity 라이브러리 모듈, 활동 간에 빌드한 통신 레이어를 보유하는 nativeCommunications 모듈, AAR을 단일 패키지로 병합하여 Maven에 전달하는 데 사용하는 packageExporter 모듈입니다.
Android Fused Library 또는 Fat AAR과 같은 이전 커뮤니티 메서드를 사용하여 여러 AAR을 병합할 수 있습니다.
Unity와 기본 활동을 동일한 프로세스에서 실행하면 구조와 통신을 간단하게 유지할 수 있습니다.
Google은 이 방법을 사용하지 않고 Unity 활동을 자체 프로세스로 분리하기로 결정했습니다. 이렇게 하면 Unity 부분이 비정상 종료되더라도 NBA 앱은 영향을 받지 않고 비정상 종료 없이 Unity를 정상적으로 종료할 수 있습니다.
이 결정으로 인해 더 강력한 프로세스 간 통신 시스템을 채택해야 했습니다.
활동이 서로 다른 프로세스에 있으므로 활동 간에 공유되는 메모리가 없으며, 많은 일반적인 Android IPC 메서드가 작동하지 않거나 요구사항을 충족하지 못합니다.
NBA의 기본 활동과 삽입된 Unity 활동 간의 통신에는 Android 인터페이스 정의 언어(AIDL)를 사용합니다.
Android와 Unity 간의 통신도 인터페이스를 기반으로 하며, Unity에 내장된 AndroidJavaProxy를 사용하여 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 인터페이스를 구현하는 서비스에 대한 참조가 포함되어 있으며, 활동이 프로세스(죄송합니다, 서비스)에 바인딩하고 내부적으로 수명 주기를 관리하는 쉬운 방법도 제공합니다.
경기 ID를 매개변수로 전달하는 콜백을 사용하여 시뮬레이션을 시작할 수 있습니다.
그런 다음 브리지는 AIDL을 통해 이를 전달하고, Unity 앱으로 전달합니다. 여기서 C# 은 명령어를 가져와 장면 구성을 시작합니다.
public class NativeInterfaceListener : AndroidJavaProxy {
[UsedImplicitly]
public void startMatch(string matchID) {
UnityThreadHelper.InvokeOnMain(() => _internalAPI.StartMatch(matchID));
}
}Unity가 활성화되고 전달하는 명령어를 누를 준비가 될 때까지 기다려야 합니다.
애플리케이션을 래핑하는 Unity 활동은 Unity 자체가 활성화되기 전에 활성화됩니다.
너무 일찍 전송된 명령어의 경우 콜백 큐에 저장합니다.
fun onStartMatchClickedInternal() {
if (isServiceBound) {
AIDLBridgeService.startMatch("match_id")
} else {
delayedCallbacksQueue.add(action)
}
}Unity가 부팅 로드 및 초기화를 완료하고 안전하다고 간주되는 상태가 되면 C# 에서 Unity가 준비되었다고 알립니다.
// After bootload and initialization
using AndroidJavaClass nativeBridge = new("com.company.UnityActivity");
using AndroidJavaObject instance = nativeBridge.GetStatic<AndroidJavaObject>("instance");
instance.Call("onUnityReady");그런 다음 큐를 반복하고 저장된 모든 명령어를 전달합니다.
@Suppress("unused") // Unity implicit call
fun onUnityReady() {
bridge.activityBridgeServiceInternal?.onUnityReady()
}
delayedCallbacksQueue.forEach { it(activityBridgeService) }이 다이어그램은 프로세스 간 통신의 작동 방식을 더 잘 보여줍니다.
보시다시피 활동 간의 브리지를 사용하여 언급한 콜백을 처리할 수 있습니다.
Unity에서 업데이트되는 StateFlow를 설정하여 인증 상태 또는 경기의 현재 상태 타임스탬프와 같은 값을 모니터링합니다.
또한 사용자 인증 정보의 암호화를 관리하고 Unity의 오류를 전달하여 NBA가 더 쉽게 디버그하고 애플리케이션 수준에서 가장 잘 처리하는 방법을 결정할 수 있도록 합니다.
오늘은 여기까지예요.
이제 조시에게 다시 마이크를 넘겨 NBA 앱에 Court View Unity 패키지를 통합하는 방법에 대해 자세히 알아보겠습니다.
이러한 Gradle 종속 항목을 사용하여 Unity를 Jetpack Compose 앱과 라이브러리로 통합합니다. XR 런타임은 3D 세션에 액세스하고, Jetpack SceneCore는 Unity 활동을 호스팅하고 공간 환경을 전환하며, XR용 Jetpack Compose는 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에서 Court View를 렌더링하는 방법을 살펴보겠습니다.
// Step 1: Request Full Space
LocalSession.current?.scene?.requestFullSpace()
// Step 2: A shared Subspace for NBA app content and embedded Unity Activity.
override fun onCreate(savedInstanceState: Bundle?){
setContent {
Subspace {
SpatialPanel { AppContent() }
// Court View
val intent = courtViewFeature.intent
val controller = rememberSpatialActivityPanelController(intent)
SpatialActivityPanel(controller)
}
}
}코트 뷰를 출시하기 전에 전체 공간을 보여드립니다.
이는 Unity를 포함하는 공간 패널을 표시하는 데 필요합니다.
다음으로 AIDL을 통해 게임 정보를 전송하여 특정 게임의 코트 뷰를 실행합니다.
그런 다음 Unity가 시작됩니다.
Android 보관 파일은 이를 Unity 활동에 대한 인텐트로 전송하고, 이를 SpatialActivityPanelController로 래핑합니다.
그런 다음 이 컨트롤러를 SpatialActivityPanel에 전달합니다.
이 패널은 코트 뷰를 담고 있으며, 기본 공간 패널과 함께 하위 공간에서 공유됩니다.
이렇게 하면 문제없이 두 기기를 한 번에 제어할 수 있습니다.
이제 모든 작업이 완료되었습니다.
루크에게 다시 넘기겠습니다.
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)
}
}
}