Android XR

Jetpack Compose for XR の 3D エンジンで没入型エクスペリエンスを向上

スピーカー

概要

講演について

3D ゲームエンジン(Godot と Unity)を Jetpack Compose と統合して、モバイルと Android XR 向けの単一のコードベースにする方法を学びます。3D シーンを空間コンテナに埋め込む方法や、Kotlin とエンジンの通信を設定する方法について説明します。また、NBA が Android XR でライブ バスケットボール エクスペリエンスを構築した方法についても詳しく紹介します。

概要

  • OpenXR Spatial Containers 拡張機能を使用すると、3D エンジンはネイティブの Android UI とスペースを共有できます。リアルタイム エンジンは、スカイボックスを乗っ取るのではなく、Jetpack XR 空間パネルとともに、管理された 3D ボリューム内で実行されます。
  • デベロッパーは、リアルタイム エンジンを AAR ライブラリとして Jetpack Compose アプリケーションに直接埋め込むことができます。このアプローチでは、ネイティブ Android がビジネス ロジックと UI を処理し、エンジンが 3D コンポーネントをレンダリングします。
  • Godot を埋め込むには、AAR 依存関係と、非表示のアセットを保持するための AAPT ビルドルールが必要です。GodotPlugin サブクラスは、Kotlin との双方向通信を処理します。
  • Compose UI ループは、継続的なエンジン ティックループから切り離された状態を維持する必要があります。エンジン アップデートを無条件に転送すると、再コンポーズが常にトリガーされ、UI の遅延が発生します。
  • NBA アプリは、プロセス間通信に AIDL を使用して、Unity を別のプロセスに埋め込みライブラリとして統合し、ホストアプリをクラッシュから保護しています。

リソース

Gemini Notebook

トピック

  • android-xr
  • jetpack-compose
  • spatial-containers
  • openxr
  • godot
  • 調和

文字起こし

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 グラス向けの没入型エクスペリエンスがあります。

  • 本日は、有線 XR グラスと XR ヘッドセット向けの没入型エクスペリエンスの構築について説明します。

  • 没入型エクスペリエンスは、Jetpack XR SDK を使用するか、リアルタイム エンジンで OpenXR を使用して作成できます。

  • Unity、Godot、Unreal をサポートしているため、どのツールを使用しても Android 向けの没入型エクスペリエンスを構築できます。

  • この講演では、Jetpack の SceneCore API を Godot および新しい OpenXR 拡張機能と組み合わせて活用し、これを実現する方法について説明します。

  • ここでは、Godot が Jetpack の空間パネルと並んで同時に実行されていることがわかります。

  • Godot が空間コンテンツとしてレンダリングされ、空間コンテナ内で 2D Compose UI とともに 3D ボリュームが提供されていることに注目してください。

  • このアプローチにより、デベロッパーは物理シミュレーションや、選択したリアルタイム エンジンを使用したカスタム シェーダーを実行しながら、アプリケーションの残りの部分にネイティブ Android 開発プラクティスを適用できます。

  • Android XR と XREAL AURA を使用すれば、このようなエクスペリエンスを作成できます。

  • この講演では、Godot と Android Studio を使用してこのエクスペリエンスを構築する方法を説明します。また、NBA と Beyond Sports のパートナーから、Unity をライブラリとして使用してこのエクスペリエンスを構築した方法について説明します。

空間コンテナ

  • これまで、OpenXR を使用して XR エクスペリエンスを構築するということは、すべてのピクセルをレンダリングするエクスペリエンスを構築することを意味していました。

  • つまり、デベロッパーが 2 つ目のアクティビティを表示する必要がある場合、両方を同時に表示することはできませんでした。

  • この問題を解決するため、OpenXR ワーキング グループである Khronos は、複数の業界リーダーと協力して OpenXR 空間コンテナ拡張機能をリリースしました。

  • 空間コンテナは、ボックス内の OpenXR と考えてください。

  • エンジンがスカイボックス全体を所有するのではなく、システム管理の 3D ボリュームが付与されます。このボリュームは、Jetpack の XR 空間パネル、他の空間コンテナ、標準の Android アクティビティと共存できます。

  • たとえば、没入型ゲームの実行中に 2 つの空間パネルを同時に表示して、Android XR を活用したいとします。

  • すべてをエンジンでレンダリングするため、これらのユースケースでは Godot エディタと空間コンテナを同時に実行できます。

  • Spatial API レベル 4 以降、Google は Android XR に空間コンテナを実装しています。

  • Spatial Containers は、2 つの重要な表示モードをサポートしています。

  • 1 つ目は有界モードです。

  • これにより、複数の組み込み回避機能を備えた定義済みの 3D ボリュームが提供されます。

  • ユーザーは空間コンテナを掴んで、回転、配置、掴む、最小化などの操作を行うことができます。また、ステレオ深度投影をサポートしているため、コンテナの端をクリッピングしながら、マジック ウィンドウや 3D ポップアウト効果を得ることができます。

  • 2 つ目は、非有界モードです。

  • ユーザーが完全な没入感を望む場合、アプリは境界モードからフルスペース モードへのシームレスな移行をリクエストできます。その後、境界モードに戻ることもできます。

  • 空間コンテナの追加により、Android XR は新しいアイデアや広範なアイデアへの扉を開きます。

  • たとえば、Android UI をゲームに組み込む場合や、アプリで 3D コンテンツを表示するためのカスタム シェーダーを作成する場合などです。

  • 空間コンテナが導入される前は、Compose とゲームエンジンを同時に実行することはできませんでした。

  • リアルタイム エンジンでのアプリケーション開発に目を向けると、こちらも実質的に 2 つの異なるパラダイムに分かれていました。

  • ネイティブ Android デベロッパーの場合、アプリケーション ランタイムは Jetpack Compose を中心に構築されています。

  • 迅速で反応の良い UI、バッテリー効率、動的なレイアウト、システム アクセシビリティ、シームレスなテキスト入力に対応するように設計されています。

  • 何も起こらないときはアイドル状態になり、予測可能です。

  • ゲームエンジン デベロッパーにとってのドメインは、Godot や Unity などのツールが活躍するリアルタイム レンダリングです。

  • ユーザーの操作を待つだけではありません。

  • 継続的なティックとレンダリングのループを実行します。

  • そのため、1 秒あたり 60、90、100 のフレームレートにすることができます。

  • 物理計算、複雑なシェーダー、スケルタル アニメーション、空間オーディオなど、すべてを驚異的なパフォーマンスで処理します。

3D エンジンを統合するためのアプローチ

  • では、両方のメリットを得たい場合はどうすればよいでしょうか。

  • 以前は、デベロッパーはこれら 2 つのアプローチのいずれかを実装する必要がありました。

  • 選択肢 A は、Godot や Unity などの実際のエンジン内でアプリケーション全体を書き換えることです。

  • 既存のコードベースを破棄するということは、ネイティブの Compose UI の多くを失い、基本的なテキストとナビゲーションを再実装し、テキスト読み上げなどの Android API のラッパーを作成することを意味します。

  • オプション B は、未加工の Vulkan API と OpenXR API を使用して、ネイティブ アプリ内に低レベルの 3D グラフィック レンダリング パイプラインをゼロから構築する方法です。

  • プロダクトの機能を提供するのではなく、独自のエンジンを構築していることになります。

  • そこで本日は、ライブラリとしての Godot とライブラリとしての Unity を最大限に活用する方法をご紹介します。

  • そして、空間コンテナの追加により、両方のメリットを享受できるようになりました。

  • これは、Jetpack アプリケーション内のライブラリにリアルタイム エンジンを直接埋め込むことで実現できます。

  • 本番環境の Android アプリの構造、ビジネス ロジック、アプリケーション内での 3D 空間コンテンツのシームレスなレンダリングは維持されます。

  • つまり、すべてのフォーム ファクタに対して 1 つの APK を使用できます。

  • 3D コンテンツを表示したくない場合は、ライブラリをアクティビティやフラグメントに読み込まず、コードベースをクリアに保つことで、多くのデバイスをサポートできます。

  • この考え方はシンプルで、アプリケーションを分離し、リアルタイム エンジンをライブラリにします。

  • Android アプリかエンジンかを選択するのではなく、エンジンを組み込みソフトウェア ライブラリ(AAR 依存関係とも呼ばれます)として扱います。

  • 他のライブラリと同様に、Gradle ビルドに直接 pull します。

  • ネイティブ Android コードは、3D コンポーネントをホストするアプリケーションの絶対的な頭脳として機能します。

  • Jetpack Compose XR は、ネイティブのアクセス可能な流動的な UI のレンダリングとアプリの状態の管理という、最も得意な処理を行います。

  • 3D 要素が必要な場合、アプリはゲームエンジン自体によってレンダリングされる埋め込みレンダリング ビューをホストします。

  • 本日は、Godot でのこの仕組みについて詳しく説明します。

  • まずは、Kotlin と GDScript の間で双方向通信を埋め込んで確立する仕組みから説明します。

  • 次に、新しい OpenXR 拡張機能である Spatial Container について説明します。これは、新世代のマルチタスクと空間レイアウトを実現するものです。

  • 先ほどお見せしたデモを簡単に見てみましょう。

  • Godot と Android Studio を使用して作成しました。

  • このクリップは、Jetpack Compose for XR がスコアボード、通知、ハイライト用のネイティブ 2D UI 空間パネルをレンダリングしている様子を示しています。

  • さらに、Godot はサッカーの試合のシミュレーションをレンダリングしています。

Kotlin と 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 エンジン ランタイムは、AAR として Maven 経由で配布されます。

  • ビルドへの統合は、標準の Android プラクティスに沿って行います。

  • すぐに注意すべき重要なステップが 1 つあります。それは、私がアセットトラップと呼んでいるものです。

    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 シーンを 1 回実行すると、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)
           }
       }
    
    }
  • そのため、アクティビティで GodotHost インターフェースを実装する必要があります。

  • これにより、アクティビティ内で発生するライフサイクル(一時停止、再開など)がすべて Godot エンジン自体に送信され、ライフサイクルが認識されるようになります。

  • 次に、ネイティブ アプリとエンジンを追加して、シームレスに通信できるようにする必要があります。

  • これを行うには、GodotHost インターフェースを使用します。

    class MainActivity : ComponentActivity(), GodotHost {
        override fun getHostPlugins(engine: Godot): Set<GodotPlugin> =
            setOf(SoccerGodotPlugin(engine))
    }
  • Godot プラグインを定義し、Godot の起動時にプラグインが Compose 側から登録されるようにします。

  • プラグインが登録されたので、Godot にシグナルを送信して通信するように Android を設定しましょう。

  • 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 Script から関数を直接呼び出すことができます。

  • この関数を使用する際に覚えておくべき簡単なヒントがあります。それは、UsedByGodot では文字列が完全に一致する必要があるということです。

  • したがって、Godot スクリプトが onMatchEvent を呼び出す場合、Kotlin 名は完全に同じである必要があります。自動的なキャメルケースやスネークケースの変換はありません。

  • そのため、両方のメソッドがまったく同じであることを確認する必要があります。

  • プラグインを接続した状態で、この講演の最も重要な部分は、私がメンタルモデルと呼んでいるものです。

  • ゲームエンジンと Jetpack Compose は、根本的に異なる 2 つのアーキテクチャで動作します。

  • ゲームエンジンは、連続したフレームティックで動作します。

  • 変更の有無にかかわらず、ゲームエンジンのティック プロセスは 1 秒間に 60 回、90 回、100 回発生し、豊かで滑らかなリアルタイム レンダリングを実現します。

  • また、Godot などのエンジンは GPU と CPU のサイクルを効果的に利用します。

  • 一方、Jetpack Compose はリアクティブに処理を行い、状態駆動型です。

  • アプリが変化していない場合、Compose は文字どおり継続し、CPU を消費しません。

  • 明確な戦略なしにこれらを接続して 2 つのランタイムを実行しようとすると、これらの理念が衝突する傾向があります。

  • では、この問題を回避する方法を見てみましょう。私はこれを「ティックリーク」と呼んでいます。

  • これは 3D エクスペリエンス(スコアボード タイマーなど)がある場所です。

  • エンジンのスクリプトには、継続的に変化する ti- タイマー変数があります。

  • この変数を Compose に無条件で転送すると、たとえば 1 秒間に 90 回転送した場合、1 秒間に 90 回この再コンポーズ ループに入ることになります。

  • つまり、Compose はコンポーザブルを再評価し、レイアウトを再測定し、UI ツリーを 1 秒間に 90 回再描画します。

  • フレーム落ちや UI の遅延が発生し、すぐにサーマル スロットリングに達します。

  • したがって、継続的なエンジンループがリアクティブ UI ループを指示しないようにすることが、鉄則となります。

  • この例では、Godot がスコアボードのタイマーを継続的に更新するのではなく、Android が必要なときに 1 秒に 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 ビューポートを実現できます。

  • 次に、Spatial Containers を 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 シェーダー、Spatial Containers を有効にできます。

  • Godot で Spatial Containers を有効にするために必要な作業は、ほぼこれだけです。

  • Godot を Android XR、空間コンテナ、そして開発したすべてのものと連携させ、空間コンテナ、空間パネル、JXR を使用するベスト プラクティスを実践できるようになりました。デベロッパー ウェブサイトで詳細を確認することを強くおすすめします。

  • ここまで、Godot Engine と Android XR で空間コンテナを使用する方法の例を見てきました。Android XR エクスペリエンスのために空間コンテナに積極的に取り組んでいるデベロッパーの話を聞きたいという方も多いのではないでしょうか。

  • 次に、NBA の Josh と Rodrigo をお招きしたいと思います。NBA は、アプリケーション内で Spatial Container を使用している最初のパートナーの 1 つです。

NBA Court View の概要

  • ありがとう、Luke。

  • こんにちは

  • 私は Josh です。NBA で Android デベロッパーをしています。

  • 本日は、Beyond Sports の Unity ソフトウェア デベロッパーである同僚の Rodrigo Sanchez と一緒に参加しています。

  • 本日は、Unity as a Library を使用して NBA アプリの Court View を作成した方法をご紹介します。

  • NBA では、常にファンに新しい体験を提供したいと考えています。

  • そこで、Google と Beyond Sports は、コートビューを導入することにしました。

  • コートビューでは、NBA の試合を 3D で再現。リーグパスの定期購入者は、通常の放送映像では不可能な新しい視点から試合を観戦できます。

  • コートのサイズを変更して回転させ、空間内の好きな場所に配置します。

  • 動き回って、ユニークな角度から試合を観戦できます。

  • その間も、同期された統計情報の表示からライブ配信の視聴まで、NBA アプリを通常どおり使用できます。

  • この設定はどのように行われているのでしょうか。

  • コートビューは Unity でレンダリングされ、Unity をライブラリとして使用する Android アプリで使用されます。

  • 次に、Maven Central でホストされる単一の Android Archive を作成します。NBA アプリは、これを単一の依存関係として pull できます。

  • これにより、Android 側と Unity 側で作業を分担することができました。

  • これにより、各メンバーが自分の得意分野に集中し、並行して作業を進めることで、開発全体をスピードアップできます。

  • たとえば、Google のチームが Compose でフルスペースのエクスペリエンスに取り組んでいる間、Unity チームは 3D コートのスケーリングとレイアウトに集中できました。

  • それでは、Rodrigo にバトンタッチして、Unity プロジェクトと、Unity as a Library の活用方法について詳しく説明してもらいます。

Unity をライブラリとして統合する

  • ありがとう、Josh。

  • Beyond Sports は、試合の実際のデータを使用して 3D ビジュアリゼーションを表現することに、数年前から NBA と共同で取り組んできました。

  • このプロジェクトでは、3D シミュレーションを Android XR に対応させました。

  • シミュレーションは、C# を使用して Unity エンジン上に構築されています。

  • Unity as a Library を使用してシミュレーションを Android にエクスポートし、Kotlin を使用してその横に通信レイヤを構築しました。

  • NBA がアプリケーションに組み込む単一の Maven 依存関係として、これら 2 つのモジュールをバンドルする前に、

  • 画面には、先ほどジョシュが紹介したのと同じシミュレーションの短い動画が表示されています。これはエディタ上で実行されているものです。

  • これを実現するには、Unity 用の Android XR Extensions パッケージを使用します。

  • プロセスをご案内します。

  • そのため、Unity でビルドする前に、適切な設定とプラグインが選択されていることを確認してください。

  • Unity の XR Plug-in Management パッケージと、Google の Android XR Extensions for Unity パッケージを入手します。

  • OpenXR Android XR Extensions プロバイダを使用するように、XR プラグイン管理設定で構成します。

  • このユースケースでは、空間コンテナを使用するため、セッション管理設定でこの機能を有効にし、没入型 XR を無効にしました。

  • すべての入力は Unity の外部でネイティブに処理されるため、XR 入力パッケージは使用しませんでした。

  • 次に、プレーヤーの設定で、プロジェクトが最小 API レベル 24 以上を使用するように設定し、アプリケーションのエントリ ポイントを新しい GameActivity に設定する必要があります。

  • [アクティビティ] のチェックボックスはオフのままにしてください。

  • ARM64 向けにビルドすることだけを考えているため、そのターゲット アーキテクチャを選択します。解像度セクションでは、[Run in Background] を有効にして、他のアプリでマルチタスクを実行しながら、3D シミュレーションをバックグラウンドで実行できるようにします。

  • また、システム権限のリクエストなどのダイアログをレンダリングするために必要な、サイズ変更可能なアクティビティも有効にします。

  • 最後に、Android XR アプリは、ユニバーサル レンダー パイプラインと Vulkan Graphics API を使用してレンダリングすることをおすすめします。

  • URP アセットで、HDR とポスト プロセッシングを無効にする必要があります。

  • これらの手順をすべて完了したら、ビルドを作成する準備は完了です。

  • ビルド ウィンドウに移動します。Unity 6.1 以降を使用している場合は、Android XR ビルド プロファイルを選択できます。これにより、Android モバイル向けにもビルドする場合に設定を分けることができます。

  • ビルドする前に、プロジェクトのエクスポートを有効にしてください。

  • Unity でのビルドが完了すると、Android Studio でアセンブルして、他の Unity APK と同様にデバイスで実行できる、動作する Android プロジェクトが作成されます。

  • 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 つのモジュールがあります。NBA のラッパー アプリケーションのモックアップ プレースホルダとしてテストするメイン APK モジュール、エクスポートされたビルドを保持する Unity ライブラリ モジュール、アクティビティ間で構築した通信レイヤを保持する nativeCommunications モジュール、AAR を 1 つのパッケージにマージして Maven に配信するために使用する packageExporter モジュールです。

  • 複数の AAR を統合するには、Android Fused Library を使用するか、Fat AAR などの古いコミュニティ メソッドを使用します。

  • Unity とメイン アクティビティを同じプロセスで実行することで、構造と通信をシンプルに保つことができます。

  • この方法は採用せず、Unity アクティビティを独自のプロセスに分離することにしました。これは、Unity 部分がクラッシュした場合に、NBA アプリが影響を受けず、クラッシュすることなく Unity を正常に終了できるようにするための安全対策です。

  • この決定により、より堅牢なプロセス間通信システムを採用する必要が生じました。

  • アクティビティは異なるプロセスに存在するため、アクティビティ間でメモリが共有されることはありません。また、一般的な Android IPC メソッドの多くは機能しないか、要件を満たしていません。

  • NBA のメイン アクティビティと埋め込み Unity アクティビティ間の通信には、Android インターフェース定義言語(AIDL)を使用しています。

  • また、Android と Unity 間の通信もインターフェースに基づいており、Unity に組み込まれている AndroidJavaProxy を使用して、Kotlin インターフェースを C# インターフェース実装に登録します。

  • Google の通信モジュールは、主にアクティビティ ブリッジと呼ばれるものに焦点を当てています。

    // 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 のブートローディングと初期化が完了し、安全と見なされる状態になったら、Unity の準備ができたことを C# から伝えます。

    // 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 がデバッグを容易にし、アプリケーション レベルで最適な処理方法を決定できるようにしています。

  • 今日はここまでだ

  • では、ジョシュにバトンを戻して、Court View Unity パッケージを NBA アプリに統合する方法について詳しく説明してもらいます。

  • これらの Gradle 依存関係を使用して、Unity をライブラリとして Jetpack Compose アプリに統合します。XR ランタイムは 3D セッションにアクセスするために使用し、Jetpack SceneCore は Unity アクティビティをホストして空間環境を切り替えるために使用します。また、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 アーカイブはこれをインテントとして Unity アクティビティに送信し、SpatialActivityPanelController でラップします。

  • そこから、そのコントローラを SpatialActivityPanel に渡します。

  • このパネルにはコートビューが表示され、メインの空間パネルとともにサブスペースで共有されます。

  • これにより、両方を同時に問題なく制御できます。

  • ユーザーはリンクをタップし このようなページに移動して参加するだけです

  • Luke に戻します。

まとめとリソース

  • ありがとうございました

  • このように、NBA は Unity と Jetpack Compose の開発を分割することで、Court View の開発を加速させることができました。

  • 空間コンテナの使用を開始する方法と、独自の Android XR アプリ内で空間コンテナを使用する方法について詳しくは、developer.android.com/develop/xr のデベロッパー向けドキュメントをご覧ください。

  • 本日はご参加いただき、誠にありがとうございました。皆様がどのようなものを構築されるのか楽しみにしています。

コード

コード スニペット

「Kotlin と Godot 間の通信」の 9 分 30 秒の Kotlin スクリプト スニペット
androidResources {
     ignoreAssetsPattern = "!.svn:!.git:!.gitignore:!.ds_store:!*.scc:<dir>_*:!CVS:!*~:!android:!export_presets.cfg"
 }
「Kotlin と Godot 間の通信」の 10:27 にある Kotlin スクリプト スニペット
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="" />
「Integrating Unity as a library」の 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")
}
}
「Integrating Unity as a library」の 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) }
}
}
「Integrating Unity as a library」の 22:36 の C# スニペット
public class NativeInterfaceListener : AndroidJavaProxy {
[UsedImplicitly]
public void startMatch(string matchID) {
   UnityThreadHelper.InvokeOnMain(() => _internalAPI.StartMatch(matchID));
}
}
「Integrating Unity as a library」の 22:54 の Kotlin スニペット
fun onStartMatchClickedInternal() {
   if (isServiceBound) {
       AIDLBridgeService.startMatch("match_id")
   } else {
       delayedCallbacksQueue.add(action)
   }
}
「Integrating Unity as a library」の 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) }
「Integrating Unity as a library」の 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)
        }
    }
}