Android XR

在 Jetpack Compose for XR 中使用 3D 引擎,提升沉浸式体验

扬声器

关于

演讲简介

了解如何将 3D 游戏引擎(Godot 和 Unity)与 Jetpack Compose 集成到单个代码库中,以便同时支持移动设备和 Android XR。我们将探索如何将 3D 场景嵌入空间容器,设置 Kotlin 与引擎之间的通信,并独家揭秘 NBA 如何在 Android XR 上打造其篮球直播体验。

摘要

  • 借助 OpenXR 空间容器扩展程序,3D 引擎可以与原生 Android 界面共享空间。实时引擎不会接管天空盒,而是在托管的 3D 体积内与 Jetpack XR 空间面板一起运行。
  • 开发者可以将实时引擎作为 AAR 库直接嵌入到 Jetpack Compose 应用中。这样,原生 Android 就可以处理业务逻辑和界面,而引擎则负责渲染 3D 组件。
  • 嵌入 Godot 需要 AAR 依赖项和 AAPT 构建规则,以保留隐藏的资源。然后,GodotPlugin 子类会处理与 Kotlin 的双向通信。
  • Compose 界面循环必须与持续的引擎 tick 循环保持分离。盲目转发引擎更新会触发持续重组,导致界面卡顿。
  • NBA 应用将 Unity 作为嵌入式库集成到单独的进程中,并使用 AIDL 进行进程间通信,从而保护宿主应用免受崩溃影响。

资源

Gemini Notebook

主题

  • android-xr
  • jetpack-compose
  • spatial-containers
  • openxr
  • godot
  • Unity

转写文稿

Android XR 简介

  • 大家好

  • 我叫 Luke Hopkins。

  • 我是 Android XR 团队的开发技术推广工程师。

  • 在今天的演讲中,我们将探讨如何将 Godot 和 Unity 等游戏引擎嵌入到 Android 项目中。

  • 稍后,来自 NBA 和 Beyond Sports 的 Josh 和 Rodrigo 将加入我们,讨论他们如何将 Unity 作为库实现到其 NBA 应用中。

  • 在本讲座中,我们将深入探讨如何将 Godot 集成到 Jetpack Compose 应用中,然后 Josh 和 Rodrigo 将介绍他们的 NBA 应用。

  • Android XR 是 Android 生态系统的延伸,它在各种设备规格的基础上进行扩展,将您熟悉和喜爱的体验带入全新维度。

  • 我们让您可以直观地将现有 XR 和 Android 应用引入这个新空间,并且我们正在利用您目前已在使用的现有 Android 技术。

  • Android XR 是一个专为各种设备设计的统一平台。

  • Android XR 设备可提供不同类型的体验。

  • 我们为音频和带屏眼镜提供了增强体验,并为扩展现实头戴设备和有线扩展现实眼镜提供了沉浸式体验。

  • 今天,我们将讨论如何为有线 XR 眼镜和 XR 头戴设备打造沉浸式体验。

  • 您可以通过我们的 Jetpack XR SDK 或使用 OpenXR 搭配实时引擎来打造沉浸式体验。

  • 我们支持 Unity、Godot 和 Unreal,因此无论您使用哪种工具,都可以为 Android 打造沉浸式体验。

  • 在本演讲中,我将介绍如何利用 Jetpack 的 SceneCore API、Godot 和新的 OpenXR 扩展程序来实现这一目标。

  • 在这里,您可以看到 Godot 与 Jetpack 的空间面板同时运行。

  • 请注意,Godot 是如何作为空间内容呈现的,它在空间容器中提供 3D 体积以及 2D Compose 界面。

  • 通过这种方法,开发者可以使用自己选择的实时引擎运行物理模拟和自定义着色器,同时将原生 Android 开发实践应用于应用的其余部分。

  • 借助 Android XR 和 XREAL AURA,您也可以打造这样的体验。

  • 在本演讲中,我们将教您如何使用 Godot 和 Android Studio 构建这种体验,然后您将听到我们的合作伙伴 NBA 和 Beyond Sports 分享他们如何使用 Unity 作为库来构建这种体验。

空间容器

  • 过去,使用 OpenXR 构建 XR 体验意味着构建一种渲染每个像素的体验。

  • 这意味着,如果开发者需要显示第二个 activity,则无法同时显示这两个 activity。

  • 为了解决这个问题,OpenXR 工作组 Khronos 与多位行业领导者合作,发布了 OpenXR 空间容器扩展。

  • 您可以将空间容器视为一个盒子中的 OpenXR。

  • 您的引擎不会拥有整个天空盒,而是会获得一个由系统管理的 3D 体积,该体积可以与 Jetpack 的 XR 空间面板、其他空间容器和标准 Android activity 共存。

  • 假设您想在运行沉浸式游戏的同时,利用 Android XR 同时显示两个空间面板。

  • 您可以在引擎中渲染所有内容,这样在这些用例中,Godot 编辑器和空间容器就可以同时运行。

  • 从 Spatial API 级别 4 开始,Google 已在 Android XR 中实现空间容器。

  • 空间容器支持两种重要的显示模式。

  • 第一种是有界模式。

  • 这提供了一个具有多个内置避让功能的定义 3D 体积。

  • 用户可以抓取空间容器,旋转、定位、抓取、最小化它,并且它支持立体深度投影,让您在剪裁容器边缘的同时,也能获得神奇窗口或 3D 弹出效果。

  • 第二种是无界限模式。

  • 如果用户决定要获得全沉浸式体验,您的应用可以请求从有界模式无缝过渡到全沉浸空间模式,然后您也可以返回到有界模式。

  • 随着空间容器的加入,Android XR 为全新且更广阔的创意打开了大门。

  • 假设您想将 Android 界面引入游戏,或者想创建自定义着色器,以便在应用中显示 3D 内容。

  • 在引入空间容器之前,无法同时运行 Compose 和游戏引擎。

  • 在实时引擎中开发应用时,我们实际上也生活在两种不同的范式中。

  • 对于原生 Android 开发者,应用运行时是围绕 Jetpack Compose 构建的。

  • 它专为快速、响应式界面、电池效率、动态布局、系统无障碍功能和无缝文本输入而设计。

  • 它在没有事件发生时处于空闲状态,并且是可预测的。

  • 对于游戏引擎开发者来说,实时渲染是他们的主战场,Godot 和 Unity 等工具在这里大放异彩。

  • 它们不会只是等待用户互动。

  • 它们会运行一个持续的 tick 和渲染循环。

  • 因此,帧速率可以是每秒 60、90、100 帧。

  • 它们可以执行物理计算、复杂的着色器、骨骼动画、空间音频,并且所有这些都具有令人难以置信的性能。

集成 3D 引擎的方法

  • 那么,如果您想兼顾这两方面,该怎么办?

  • 以前,开发者必须实现这两种方法之一。

  • 选项 A 是在 Godot 或 Unity 等真正的引擎中重写整个应用。

  • 丢弃现有代码库意味着您将失去大量原生 Compose 界面,需要重新实现基本文本和导航,并围绕 Android API(例如文字转语音)编写封装容器。

  • 选项 B 是在原生应用中使用原始 Vulkan 和 OpenXR API 从头开始构建底层 3D 图形渲染流水线。

  • 您实际上是在构建自己的专有引擎,而不是提供产品功能。

  • 今天,我将向大家展示如何充分利用 Godot 库和 Unity 库。

  • 现在,有了空间容器,您就可以兼得两者的优势。

  • 为此,我们可以将实时引擎直接嵌入到 Jetpack 应用内的库中。

  • 您可以保留正式版 Android 应用结构、业务逻辑,并在应用内无缝呈现 3D 空间内容。

  • 这也意味着,您可以为所有设备规格提供一个 APK。

  • 如果您不想只显示 3D 内容,可以选择不将该库加载到 activity 或 fragment 中,并保持代码库清晰,这样您就可以支持许多设备。

  • 其理念很简单,就是将应用解耦,并将实时引擎作为一个库。

  • 您无需在 Android 应用或引擎之间做出选择,而是将引擎视为嵌入式软件库,也称为 AAR 依赖项。

  • 您可以像使用任何其他库一样,直接将其拉取到 Gradle build 中。

  • 您的原生 Android 代码仍然是托管 3D 组件的应用的绝对核心。

  • Jetpack Compose XR 负责处理其最擅长的工作:渲染原生、无障碍、流畅的界面,以及管理应用状态。

  • 当需要 3D 元素时,应用会托管一个由游戏引擎本身渲染的嵌入式渲染视图。

  • 今天,我们将深入探讨 Godot 的底层工作原理。

  • 首先,我将介绍在 Kotlin 和 GDScript 之间嵌入和建立双向通信的机制。

  • 然后,我们将介绍新的 OpenXR 扩展程序 Spatial Container,它将真正开启新一代多任务处理和空间布局。

  • 我们来快速回顾一下我之前展示的演示。

  • 我使用 Godot 和 Android Studio 制作了这款游戏。

  • 此片段展示了 Jetpack Compose for XR 为记分板、通知和精彩集锦渲染原生 2D 界面空间面板。

  • 除此之外,Godot 还在渲染一场足球比赛的模拟画面。

Kotlin 与 Godot 之间的通信

  • 在这里,您可以看到我们如何从 Kotlin 与 GDScript 进行通信。

  • 为此,Godot 提供了插件架构。

  • 您需要编写一个扩展 GodotPlugin 的轻量级 Kotlin 类。

  • 此类充当 JVM 与 Godot 的 C++ 运行时之间的接口。

  • 因此,您的应用与 Godot 之间的互动非常轻量。

  • Godot 的架构支持 Godot 与 Kotlin 之间的双向通信。

  • 当 Godot 中发生游戏事件(例如进球或犯规)时,Godot 可以直接调用 Kotlin 函数并实时更新 Compose 界面。

  • 然后,当用户与媒体播放器互动时(例如,暂停或选择不同的精彩集锦),Kotlin 会向 Godot 发回信号,并执行相应的游戏引擎操作。

  • Godot 插件一旦跨越,性能和管理就会简单地沿着网域线进行。

  • 应用 Android 应用的最佳标准实践和 Godot 项目的最佳标准实践。

  • 现在,我们来看看架构。

  • 我们来看看如何将此功能集成到 Godot 中,进而集成到 Android 应用中。

  • Godot 引擎运行时通过 Maven 以 AAR 形式分发。

  • 集成到 build 中只需遵循标准 Android 做法。

  • 有一个重要步骤需要立即注意,那就是我所说的“素材资源陷阱”。

    androidResources {
         ignoreAssetsPattern = "!.svn:!.git:!.gitignore:!.ds_store:!*.scc:<dir>_*:!CVS:!*~:!android:!export_presets.cfg"
     }
  • 在 Godot 项目中,场景和项目配置文件位于同一位置。

  • 嵌入 Godot 时,Godot 项目会直接添加到 assets 文件夹中。

  • 这意味着,所有导入的 3D 网格、压缩纹理和编译纹理都会作为运行时工件存储在名为 .godot 的隐藏文件中。

  • 默认情况下,Android 在构建时会移除此文件夹,因此会忽略任何带有英文句点的目录。

  • 因此,如果您正常同步和构建应用,APK 会构建,但会崩溃并加载,因为这些内容尚未编译到 APK 中。

  • 因此,如需解决此问题,您只需在 build.gradle 中明确替换 AAPT 选项,并允许 Godot 生成前导点。

  • 完成此操作后,您可以在 Godot 编辑器中运行一次 Godot 场景,然后就可以在 Android Studio 中构建其他所有内容。

  • 为了支持空间容器和最新的 OpenXR,我建议使用 Godot 4.8 及更高版本、Godot 供应商扩展程序 5.2 和 Khronos OpenXR 加载器 1.1.63。

    dependencies {
        // Godot 4.8 Android Library + Godot OpenXR Vendors plugin + Khronos OpenXR Loader
        implementation("org.godotengine:godot:4.8.0.dev-SNAPSHOT")
        implementation("org.godotengine:godot-openxr-vendors-androidxr:5.1.0-stable")
        implementation("org.khronos.openxr:openxr_loader_for_android:1.1.63")
    
        // Jetpack XR SDK (Compose for XR, SceneCore, Runtime)
        implementation("androidx.xr.compose:compose:1.0.0-beta02")
        implementation("androidx.xr.scenecore:scenecore:1.0.0")
        implementation("androidx.xr.runtime:runtime:1.0.0")

在 Jetpack Compose 中渲染 Godot

  • 资源打包后,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)
           }
       }
    
    }
  • 因此,您的 activity 需要实现 GodotHost 接口。

  • 这样可以确保 Activity 中发生的任何生命周期事件(例如暂停、恢复)都会发送到 Godot 引擎本身,从而使其能够感知生命周期。

  • 接下来,我们需要添加原生应用和引擎,以便它们无缝通信。

  • 为此,我们可以使用 GodotHost 接口。

    class MainActivity : ComponentActivity(), GodotHost {
        override fun getHostPlugins(engine: Godot): Set<GodotPlugin> =
            setOf(SoccerGodotPlugin(engine))
    }
  • 我们可以定义 Godot 插件,并确保在 Godot 启动时,该插件已从 Compose 端注册。

  • 现在插件已注册,接下来我们设置 Android,让它通过向 Godot 发出信号来进行通信。

  • 在 Kotlin 代码中,我们将创建 GodotPlugin 的实例。

  • 我们将让 activity 向 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)
  • 因此,首先您需要使用 UsedByGodot 为 Kotlin 函数添加注解。

    @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 在两个根本上(例如,相反的架构)运行的地方。

  • 游戏引擎在连续的帧 tick 上运行。

  • 无论您是否进行了任何更改,游戏引擎的 tick 过程都会以每秒 60、90、100 次的速度运行,从而提供丰富流畅的实时渲染。

  • Godot 等引擎可以有效利用 GPU 和 CPU 周期。

  • 另一方面,Jetpack Compose 以响应式方式处理事务,并且由状态驱动。

  • 当应用没有变化时,Compose 会继续运行,但会消耗 0 CPU。

  • 如果您想在没有明确策略的情况下连接这些运行时来运行两个运行时,这些理念往往会发生冲突。

  • 那么,我们来看看如何通过我所说的“tick 泄露”来解决这个问题。

  • 这里有一个 3D 体验,比如记分牌计时器。

  • 在引擎脚本中,有一个 ti- 计时器变量会不断变化。

  • 如果您盲目地将此变量转发到 Compose,假设每秒 90 次,那么您将每秒进入此重组循环 90 次。

  • 这意味着,Compose 将每秒重新评估可组合项、重新测量布局、重新绘制界面树 90 次。

  • 您会看到丢帧、界面延迟,并且很快就会遇到热节流。

  • 因此,黄金法则是:绝不让持续引擎循环主导响应式界面循环。

  • 在此示例中,我没有让 Godot 持续更新计分板计时器,而是确保 Android 在需要时每秒拉取一次此数据。

    LaunchedEffect(uiState.phase) {
        while (uiState.phase == MatchPhase.PLAYING) {
            delay(1000L) // Update scoreboard timer at 1 Hz, not 90 Hz
            SoccerGodotPlugin.tickLocalClock()
        }
    }
    ...
    @UsedByGodot
    fun onMatchEvent(eventTypeStr: String, matchMinute: Int, ...) {
        // Recompose once when threshold is crossed to display the popup banner
        _uiState.update { it.copy(activeBannerEvent = event, ...) }
    }
  • 最后,请始终牢记线程边界。

  • Godot 游戏引擎循环会在其自己的渲染线程上持续执行。

  • 界面,Android 界面必须在 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 项目添加空间容器。

  • 首先,我们将应用设置为全沉浸空间,启用空间容器及其边界提示,然后在 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 中启用空间容器所需执行的全部操作。

  • 现在,您已经了解了如何让 Godot 支持 Android XR、空间容器搭配使用,以及如何开发这些内容,还了解了使用空间容器、空间面板和 JXR 的最佳实践,我们强烈建议您访问开发者网站,了解更多信息。

  • 我们刚刚通过一个示例,介绍了如何在 Godot 引擎和 Android XR 中使用空间容器。相信大家一定很想听听一位正在积极为 Android XR 体验开发空间容器的开发者分享他的心得。

  • 接下来,我想邀请 NBA 的 Josh 和 Rodrigo 上台,他们是首批在应用中使用空间容器的合作伙伴之一。

NBA Court View 概览

  • 谢谢,Luke。

  • 大家好!

  • 我叫 Josh,是 NBA 的一名 Android 开发者。

  • 今天,我的同事 Rodrigo Sanchez 也来到了现场,他是 Beyond Sports 的 Unity 软件开发者。

  • 今天,我们想向大家展示如何使用 Unity as a Library 为 NBA 应用打造 Court View。

  • 在 NBA,我们一直在努力为球迷带来新的体验。

  • 因此,我们很高兴能与 Google 和 Beyond Sports 携手推出“球场视角”。

  • Court View 提供 NBA 比赛的 3D 重现,让 League Pass 订阅者能够从普通直播视频无法提供的全新视角观看比赛。

  • 缩放并重新旋转球场,将其放置在空间中的任意位置。

  • 您可以围绕它移动,从独特的角度观看比赛。

  • 在此期间,你可以像往常一样使用 NBA 应用,从查看同步统计数据到观看直播,一切照旧。

  • 如何设置这一切?

  • 球场视图在 Unity 中渲染,并由我们的 Android 应用使用 Unity 作为库来使用。

  • 然后,我们生成一个托管在 Maven Central 上的 Android Archive,NBA 应用可以将其作为单个依赖项拉取。

  • 这样,我们就可以将工作拆分为 Android 端和 Unity 端。

  • 这样,我们每个人都可以专注于自己最擅长的领域,并并行工作,从而加快整体开发速度。

  • 例如,当我们的团队在 Compose 中打造全沉浸空间体验时,Unity 团队可以专注于 3D 球场的缩放和布局。

  • 现在,我将把时间交给 Rodrigo,由他来详细介绍 Unity 项目以及他们如何使用 Unity as a Library。

将 Unity 作为库集成

  • 谢谢你,Josh。

  • Beyond Sports 与 NBA 合作多年,致力于利用比赛的真实数据呈现 3D 可视化效果。

  • 在这个项目中,我们调整了 3D 模拟,使其与 Android XR 兼容。

  • 我们的模拟系统基于 Unity 引擎,使用 C# 语言构建。

  • 我们使用 Unity as a Library 将模拟导出到 Android,并使用 Kotlin 在其旁边构建了一个通信层。

  • 然后将这两个模块打包成一个 Maven 依赖项,供 NBA 纳入其应用中。

  • 在屏幕上,您可以看到 Josh 之前展示的模拟视频,只是这次是在编辑器中运行。

  • 为此,我们使用了适用于 Unity 的 Android XR 扩展程序软件包。

  • 下面我来为您介绍一下具体流程。

  • 因此,在 Unity 中构建之前,请确保您已选择正确的设置和插件。

  • 获取 Unity 的 XR 插件管理软件包和 Google 的 Android XR Extensions for Unity 软件包。

  • 在 XR 插件管理设置中配置这些设置,以使用 OpenXR Android XR 扩展程序提供程序。

  • 对于我们的用例,我们希望使用空间容器,因此我们在会话管理设置中启用了该功能,并停用了沉浸式 XR。

  • 我们没有使用 XR 输入软件包,因为所有输入都是在 Unity 之外以原生方式处理的。

  • 接下来,在播放器设置中,您需要将项目设置为使用 24 或更高级别的最低 API 级别,并且必须将应用入口点设置为较新的 Game Activity。

  • 请注意,必须取消选中“活动”。

  • 我们只想针对 ARM64 进行构建,因此选择该目标架构,并在分辨率部分启用“Run in Background”(在后台运行),以便您在使用其他应用执行多任务时,让 3D 模拟在后台运行。

  • 我们还启用了“可调整大小的 activity”,因为这是呈现对话框(例如系统权限请求)所必需的。

  • 最后,建议使用通用渲染管线和 Vulkan 图形 API 渲染 Android XR 应用。

  • 在 URP 资源中,应停用 HDR 和后期处理。

  • 完成所有这些步骤后,您就可以开始构建了。

  • 转到 build 窗口,如果您使用的是 Unity 6.1 或更高版本,则可以选择 Android XR build 配置文件,这样一来,如果您还针对 Android 移动设备进行 build,则可以单独设置。

  • 在构建之前,请务必启用导出项目。

  • Unity 完成构建后,您将获得一个可正常运行的 Android 项目,该项目可在 Android Studio 中组装,并像任何其他 Unity APK 一样在设备上运行。

  • 进入 Android Studio 后,您需要将导出的 build 转换为库,以供其他应用使用。

    // The Unity build
    include(":unityLibrary")
    project(":unityLibrary").projectDir = File("..\\Libraries\\unityBuild\\unityLibrary")
    
    // Custom communication IPC module
    include(":nativeCommunications")
    project(":nativeCommunications").projectDir = File("..\\Libraries\\nativeCommunications")
    
    // If using XR features
    include(":unityLibrary:xrmanifest.androidlib")
    
    // Used for merging libraries, exporting and publishing.
    include(":packageExporter")
    
    dependencyResolutionManagement {
       repositories {
    flatDir { // Required to load unity correctly
       dirs("${project(":unityLibrary").projectDir}/libs")
    }
    }
  • 在我们的项目中,有四个不同的模块:主 APK 模块,用于测试 NBA 封装应用的模拟占位符;Unity 库模块,用于存放导出的 build;nativeCommunications 模块,用于存放我们在 activity 之间构建的通信层;以及 packageExporter 模块,用于将 AAR 合并到单个软件包中,以便交付给 Maven。

  • 您可以使用 Android Fused Library 或 Fat AAR 等旧版社区方法合并多个 AAR。

  • 您可以让 Unity 和主 activity 在同一进程中运行,从而保持结构和通信简单。

  • 我们没有这样做,而是决定将 Unity activity 分离到其自己的进程中,作为一种保障措施,这样即使 Unity 部分崩溃,NBA 应用也不会受到影响,并且可以安全地处理正常关闭 Unity 的操作,而不会自行崩溃。

  • 这一决定迫使我们采用更强大的进程间通信系统。

  • 由于这些 activity 位于不同的进程中,因此它们之间没有共享内存,并且许多常见的 Android IPC 方法都无法正常运行或无法满足要求。

  • 我们使用 Android 接口定义语言(即 AIDL)在 NBA 的主要 activity 与嵌入式 Unity activity 之间进行通信。

  • 然后,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) }
    }
    }
  • 这两个 activity 都可以访问 activity 桥接及其中的实用程序方法。

  • 该桥接器包含对实现我们定义的 AIDL 接口的服务的引用,还提供了一种简单的方法,让 activity 能够绑定到进程(抱歉,是绑定到服务),并在内部管理其生命周期。

  • 您可以通过回调来启动模拟,在回调中,我们将比赛 ID 作为参数传递。

  • 然后,桥接器通过 AIDL 将其传递给 Unity 应用,C# 收到命令并开始配置场景。

    public class NativeInterfaceListener : AndroidJavaProxy {
    [UsedImplicitly]
    public void startMatch(string matchID) {
       UnityThreadHelper.InvokeOnMain(() => _internalAPI.StartMatch(matchID));
    }
    }
  • 我们确实需要等待 Unity 启动并准备好处理我们传递给它的命令。

  • 封装应用的 Unity activity 将在 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) }
  • 此图更好地说明了我们的进程间通信的工作原理。

  • 如您所见,通过在 activity 之间使用桥接,我们可以处理前面提到的那些回调。

  • 我们设置了从 Unity 更新的 StateFlow,用于监控身份验证状态或比赛的当前状态时间戳等值。

  • 此外,我们还负责管理凭证的加密,并转发来自 Unity 的任何错误,以便 NBA 能够更轻松地进行调试,并确定如何在应用层面以最佳方式处理这些错误。

  • 我就先说这些吧

  • 现在,我将把舞台交还给 Josh,让他详细介绍他们如何将 Court View Unity 软件包集成到 NBA 应用中。

  • 我们使用以下 Gradle 依赖项将 Unity 作为库集成到 Jetpack Compose 应用中:XR Runtime 用于访问 3D 会话,Jetpack SceneCore 用于托管 Unity activity 并切换空间环境,Jetpack Compose for 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)
            }
        }
    }
  • 在启动球场视图之前,我们会先带您进入全沉浸空间。

  • 必须执行此操作,才能显示包含 Unity 的空间面板。

  • 接下来,我们通过 AIDL 发送游戏信息,以启动特定游戏的球场视图。

  • 然后,Unity 本身会启动。

  • 我们的 Android 归档会将其作为 intent 发送到 Unity activity,并将其封装在 SpatialActivityPanelController 中。

  • 然后,只需将该控制器传递给 SpatialActivityPanel 即可。

  • 此面板用于容纳球场视图,并与主空间面板共享子空间。

  • 这样我们就可以同时控制这两个设备,而不会出现任何问题。

  • 就是这么简单

  • 我将把话筒交还给 Luke。

结论和资源

  • 非常感谢。

  • 如您所见,NBA 通过将 Unity 和 Jetpack Compose 开发工作分开,成功加快了 Court View 的开发速度。

  • 如需详细了解如何开始使用空间容器,以及如何在您自己的 Android XR 应用中使用空间容器,请参阅 developer.android.com/develop/xr 上的开发者文档。

  • 非常感谢大家今天的参与,我们迫不及待想看到您的杰作。

代码

代码段

“Kotlin 与 Godot 之间的通信”中 09:30 处的 Kotlin 脚本代码段
androidResources {
     ignoreAssetsPattern = "!.svn:!.git:!.gitignore:!.ds_store:!*.scc:<dir>_*:!CVS:!*~:!android:!export_presets.cfg"
 }
“Kotlin 与 Godot 之间的通信”中 10:27 处的 Kotlin Script 代码段
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")
“在 Jetpack Compose 中渲染 Godot”视频中 10:48 处的 Kotlin 代码段
@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 }
    )
}
“在 Jetpack Compose 中渲染 Godot”视频中 10:55 处的 Kotlin 代码段
Subspace {
        SpatialPanel(
            modifier = SubspaceModifier
                .width(960.dp)
                .height(540.dp)
                .offset(z = (-200).dp)
                .movable()
,
    ) {
        GodotView(
            godotHost = godotHost,
            modifier = Modifier.fillMaxSize(),
        )
    }
}
“在 Jetpack Compose 中渲染 Godot”视频中 11:05 处的 Kotlin 代码段
@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)
       }
   }

}
“在 Jetpack Compose 中渲染 Godot”视频中 11:29 处的 Kotlin 代码段
class MainActivity : ComponentActivity(), GodotHost {
    override fun getHostPlugins(engine: Godot): Set<GodotPlugin> =
        setOf(SoccerGodotPlugin(engine))
}
“在 Jetpack Compose 中渲染 Godot”视频中 11:52 处的 Kotlin 代码段
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),
    )

}
“在 Jetpack Compose 中渲染 Godot”视频中 12:01 处的文字片段
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)
“在 Jetpack Compose 中渲染 Godot”视频中 12:10 处的 Kotlin 代码段
class SoccerGodotPlugin(godot: Godot) : GodotPlugin(godot) {

    fun sendCommandToGodot(command: String) {
        emitSignal("match_command", command)
    }
}
“在 Jetpack Compose 中渲染 Godot”视频中 12:20 处的文字片段
func _on_goal_scored(minute: int, home: int, away: int) -> void:

    if soccer_plugin:
        soccer_plugin.onMatchEvent("GOAL", minute, home, away)
“在 Jetpack Compose 中渲染 Godot”视频中 12:30 处的 Kotlin 代码段
@UsedByGodot
fun onMatchEvent(type: String, minute: Int, home: Int, away: Int, title: String) {

        _uiState.update { state ->
            state.copy(homeScore = home, awayScore = away, matchSeconds = minute)
        }
    }
“在 Jetpack Compose 中渲染 Godot”视频中 14:35 处的 Kotlin 代码段
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, ...) }
}
“在 Jetpack Compose 中渲染 Godot”视频中 14:57 处的 Kotlin 代码段
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
       )
   }
“在 Jetpack Compose 中渲染 Godot”视频中 15:22 处的 XML 代码段
<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="" />
“将 Unity 作为库集成”视频中 20:41 处的 Kotlin 脚本代码段
// 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")
}
}
“将 Unity 作为库集成”中 22:11 处的 Kotlin 代码段
// 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) }
}
}
“将 Unity 作为库集成”中 22:36 处的 C# 代码段
public class NativeInterfaceListener : AndroidJavaProxy {
[UsedImplicitly]
public void startMatch(string matchID) {
   UnityThreadHelper.InvokeOnMain(() => _internalAPI.StartMatch(matchID));
}
}
“将 Unity 作为库集成”中 22:54 处的 Kotlin 代码段
fun onStartMatchClickedInternal() {
   if (isServiceBound) {
       AIDLBridgeService.startMatch("match_id")
   } else {
       delayedCallbacksQueue.add(action)
   }
}
“将 Unity 作为库集成”中 22:57 处的 C# 代码段
// After bootload and initialization

using AndroidJavaClass nativeBridge = new("com.company.UnityActivity");
using AndroidJavaObject instance = nativeBridge.GetStatic<AndroidJavaObject>("instance");
instance.Call("onUnityReady");
“将 Unity 作为库集成”中 23:06 处的 Kotlin 代码段
@Suppress("unused") //  Unity implicit call
fun onUnityReady() {
   bridge.activityBridgeServiceInternal?.onUnityReady()
}

delayedCallbacksQueue.forEach { it(activityBridgeService) }
“将 Unity 集成为库”视频中 23:50 处的 Kotlin 脚本代码段
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")
}
“将 Unity 作为库集成”中 24:10 处的 Kotlin 代码段
// 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)
        }
    }
}