メインコンテンツまでスキップ
バージョン: 1.0.4

Flutter x Unity as a LibraryでMiRZAのスマホ側UIを作成

WARNING

本ページに記載の環境は本記事執筆時点(2025/12/24)のものです。
アップデートによって変更が生じている可能性がある点ご留意ください。

0. はじめに

今回は無線のXRグラスであるMiRZA®(ミルザ)Unity as a Libraryを組み合わせ、スマートフォン連携の強みを活かしたアプリ作りを考えていきます。

MiRZAとは、NTTコノキューデバイスから2024年10月16日に発売された6DoF対応のXRグラスのことです。(MiRZAの詳細についてはこちら

1. 実現したいこと

1.1 MiRZAの強み

1.1.1 MiRZAの特長

MiRZA最大の特長はスマートフォンと完全無線で接続でき、スマートフォンを手に持つこともカバンやポケットに入れたままにすることも出来る点です。

スマートフォンをリモコンのように扱い操作することに加えて、グラス側面にはタッチセンサが付いており、スマートフォンを使わず直接操作も可能です。

カメラやマイクを搭載しているため、MiRZAはアプリケーション次第で自分の目や耳の代わりにもなります。

1.1.2 ARグラス/スマートグラス時代におけるスマートフォンの立ち位置

XRにおけるインタラクション、操作方法は現在過渡期とも言える段階です。

  • コントローラー(専用/スマートフォン):MiRZA対応
  • ハンドトラッキング:MiRZA対応
  • アイトラッキング
  • スマートリング
  • 脳波

最終的に何に落ち着くのか?これは誰にも分かりません。ただ一つ言えることは、「人はそんな早くには適応できない」ということです。

ガラケーからスマートフォンに変化した際かなりの慣れが必要であったように、タッチ操作からハンドトラッキング等に移行するには時間を要します。

私個人は**「ARグラスやスマートグラスが普及してもしばらくスマートフォンは残る」**と考えています。インタラクションの慣れの問題に加えて、MiRZAがスマートフォンを母艦としているように計算機としては非常に優秀だからです。

MiRZAに限らず、近年発表されているスマートグラスはスマートフォンとの接続が前提の物が多いです。であれば、そんな時代に向けたインタラクション・アプリの作りを構想する必要があります。

1.2 Unity as a Libraryの強み

Unity as a LibraryというUnityをライブラリとして扱い、通常のモバイルアプリ等に組み込む技術を使うことで、スマートグラスのアプリは非常に慣れ親しみやすいものとなります。

今回はNativeアプリではなく、**Unityと同様クロスプラットフォーム開発が可能なFlutterを採用することで、Unityのクロスプラットフォームの強みを残しつつUIをモダンな見た目に、宣言的に書くことが出来る”いいとこ取り”**を目指します。

2. Flutter x Unity as a Library環境構築

2.1 環境情報

本記事のアプリは下記環境で作られています。

Unity 6000.0.58f2
Flutter 3.38.4
Snapdragon Spaces 1.0.4
QCHT4.1.14
MiRZA Configuration Tools 1.0.4
MiRZA Library 1.2.1
Rive 0.3.8-canary.117

2.2 Unityプロジェクトの作成(通常のDRFプロジェクト作成)

MiRZAは開発ドキュメントやSDKが非常に充実しており、Spaces SDKに加えてMiRZA SDK, MiRZA Libraryを利用することでエディタ/実機問わず開発することが可能です。

今回は環境構築やタッチパッドにMiRZA SDKを、3.にて後述する任意のタイミングでMiRZAと接続/切断するためMiRZA Libraryを活用しています。

Unity as a Libraryであっても、通常のMiRZA x Dual Render Fusionの開発と基本的に同様です。

スマホ側のUIをUnity as a Libraryに合わせて変更しても良いですし、一切手を加えず完全にFlutterのUIを上から重ねてUnityを触らないようにすることも出来ます。

今回作成したアプリにおいても、Unity起動~グラス接続中・切断後は完全に上からUnityを隠しています。

ただし、スマホ側でポインターのようなコントローラーを用意したい場合は4.で後述する設計上の都合からUnity側で用意することを推奨します。

また、今回はflutter_unity_widgetを利用しているためflutter_unity_widgetのunitypackageに含まれるビルドスクリプトでビルドします。
Unity6を利用している場合はUnity6ブランチを利用する必要があります。

2.3 Flutterプロジェクトの作成

Flutterプロジェクトも基本的に通常のFlutterと変わりありません。flutter_unity_widgetを利用するためUnityを1つのWidgetとして扱うことが出来ます。

FlutterはAI開発とも相性が良く、高い表現力や操作性が魅力です。

フッターのタブを切り替えるとフワンと白くエフェクトが出る部分などはUnityで表現することが難しい例です。

2.4 Spaces SDK x Unity as a Library固有の課題

2.4.1 app/build.gradle.ktsへの依存関係の追加

Spaces SDKが持つ依存関係を登録しないとビルド時にエラーになります。

通常のUnityアプリであればbuild.gradleに必要な依存関係は自動で追加されますが、Unity as a Libraryの場合Native(Flutter)側のAndroid Projectに依存関係を追加する必要があります。

具体的には以下のようにします。Flutterの場合、android/app/build.gradle.kts です。

dependencies {
implementation(project(":unityLibrary"))
implementation(project(":flutter_unity_widget"))

// AppCompat and Material Components for Unity integration
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("com.google.android.material:material:1.11.0")

// Navigation Component for SpacesActivities
implementation("androidx.navigation:navigation-fragment:2.7.6")
implementation("androidx.navigation:navigation-ui:2.7.6")
}

2.4.2 アプリアイコンが2つになってしまう問題を解決する

これもSpaces SDKが関係しており、AndroidアプリにおいてNative(Flutter)/Spacesのcategory.LAUNCHERを持つActivityが2つ存在することから、アプリインストールをするとアプリが2つホーム上存在してしまいます。

どちらを起動しても挙動は同じであるため動作に支障はないのですが混乱しないよう削除することを推奨します。

具体的にはAndroidManifest.xmlを以下のようにします。

  <application android:label="@string/app_name" android:name="${applicationName}" android:icon="@mipmap/ic_launcher">
<activity android:name=".MainActivity" android:exported="true" android:launchMode="singleTop" android:taskAffinity="" android:theme="@style/LaunchTheme" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:hardwareAccelerated="true" android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data android:name="io.flutter.embedding.android.NormalTheme" android:resource="@style/NormalTheme"/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>

<!-- Snapdragon Spaces の SplashScreenActivity(ヘッドレス起動用) -->
<activity
android:name="com.qualcomm.snapdragon.spaces.splashscreen.SplashScreenActivity"
android:exported="true"
tools:node="merge">

<!-- まず AAR 側の intent-filter を全削除してクリーンにする -->
<intent-filter tools:node="removeAll" />

<!-- ★ Spaces Home / XR ランチャー用だけを自前で定義(LAUNCHER は付けない) -->
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<!-- 必要なら DEFAULT を追加(環境によってはこれで2アイコンになる場合もある) -->
<!-- <category android:name="android.intent.category.DEFAULT" /> -->
<category android:name="com.qualcomm.qti.intent.category.SPACES" />
</intent-filter>
</activity>

<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data android:name="flutterEmbedding" android:value="2"/>
</application>

3.  DRFの接続制御(MiRZAの任意接続)

MiRZA LibraryとSpaces SDKのDynamic OpenXR Loaderを使うことでMiRZAを任意のタイミングでアプリと接続・切断することが出来ます。

これにより、真にARが必要な場面以外ではMiRZAの電池をセーブしながらアプリを利用することが出来ます。

接続シーケンスはMiRZAドキュメントを必ず参照してください。また、難易度としては高くある程度MiRZAアプリ開発に慣れた状態で着手することをお勧めします。

3.1 MRモードを起動しMiRZAに接続する方法

接続シーケンスはドキュメントに記載されています。利用元クラスをご自身のアプリのスクリプトをお考え下さい。

アプリが起動し、MiRZAの画面上MiRZAとスマホが接続されている場合、GlassConnectionStateはConnectedになっています。この状態でMiRZA LibraryでSpacesModeOnを呼び出すと一度接続は切れますが次第にMRモードがオンになり、再度GlassConnectionState:ConnectedになったらStartOpenXRを呼ぶことで映像が表示されます。

                // 手順1: Spaces Glass StatusがOnになるまで待つ
// TODO:タイムアウト処理
Debug.Log($"[XrConnector][Connect][Phase1] MiRZAアプリへの接続を待機中");
await UniTask.WaitUntil(() => _spacesConnectionState == SpacesGlassStatus.ConnectionState.Connected, cancellationToken: token);

// 手順2: SpacesMode:Offの場合Onにする(MRモード起動)
// Editorではスキップ
#if !UNITY_EDITOR && UNITY_ANDROID
Debug.Log($"[XrConnector][Connect][Phase2] SpacesModeOnAsyncの実行");
if (_spacesMode == SpacesModeStatus.Off)
{
_mirzaPlugin.SpacesModeOnAsync(result =>
{
if (result == null || result.State != com.nttqonoq.devices.android.mirzalibrary.State.Success)
{
Debug.LogError($"[XrConnector] Failed to turn on MR Mode. Error: {result?.ErrorMessage}");
_requestCts?.Cancel();
}
else
{
Debug.Log(
"[XrConnector] Change MR Mode command sent successfully. Waiting for status change event.");
}
});
}

// 手順3: SpacesMode:Onになるまで待機 
Debug.Log($"[XrConnector][Connect][Phase3] MRモード有効化の完了待機");
await UniTask.WaitUntil(() => _spacesMode == SpacesModeStatus.On, cancellationToken: token);

Debug.Log($"[XrConnector][Connect][Phase4] グラスが一度切断されるまで待つ");
await UniTask.WaitUntil(() => _spacesConnectionState != SpacesGlassStatus.ConnectionState.Connected, cancellationToken: token);
#endif

// 手順4: SpacesMode有効化で一時切断されたSpacesGlassStatusがConnectedになるまで待つ
Debug.Log($"[XrConnector][Connect][Phase5] グラスがもう一度接続されるまで待つ");
await UniTask.WaitUntil(() => _spacesConnectionState == SpacesGlassStatus.ConnectionState.Connected, cancellationToken: token);

// 手順5: OpenXRの開始(MiRZAのトラッキング・映像出力)
Debug.Log($"[XrConnector][Connect][Phase6] Start OpenXR");
DynamicOpenXRLoader.Instance.StartOpenXR();
await UniTask.WaitUntil(() => _openxrState == DynamicOpenXRLoader.OpenXRState.OpenXRStarted, cancellationToken: token);

動画では、SpacesMode:On, GlassConnectionState:Connectedを検知してUnityのタッチパッド画面を表示しています。

注:うまくいかない場合、まずはMiRZAアプリでMiRZAの画面が表示されるか確認し、「グラス表示を終了」を押して数秒後ご自身のアプリを起動してください。
MiRZAアプリでも接続できない場合、スマートフォンおよびMiRZAを再起動してください。

3.2 MRモードを終了しMiRZAを切断する方法

SpacesModeOffを呼び出し、StopOpenXRすることでMiRZAと切断します。

注:QCHTを利用している場合、クラッシュする恐れがあるため先にハンドトラッキングを終了してください

            // 手順1: SpacesMode:Onの場合Offにする(MRモード終了)
if (_spacesMode == SpacesModeStatus.On)
{
_mirzaPlugin.SpacesModeOffAsync(result =>
{
if (result == null || result.State != com.nttqonoq.devices.android.mirzalibrary.State.Success)
{
Debug.LogError($"[XrConnector] Failed to turn on MR Mode. Error: {result?.ErrorMessage}");
_requestCts?.Cancel();
}
else
{
Debug.Log(
"[XrConnector] Change MR Mode command sent successfully. Waiting for status change event.");

// 手順2: OpenXRを終了する
DynamicOpenXRLoader.Instance.StopOpenXR();
}
});
}
await UniTask.WaitUntil(() => _openxrState != DynamicOpenXRLoader.OpenXRState.OpenXRStarted, cancellationToken: token);

4.  Flutter⇔Unityメッセージの勘所

4.1 Unity as a Libraryの制約

Flutter(Native)->Unityへのメッセージは指定のGameObjectに指定の関数、stringしか渡すことが出来ません。そのため、複雑なメッセージングや状態同期はテスト性や煩雑さの意味でお勧めしません。

特に、画像や3Dモデルのやり取りはファイルそのものをやり取りするのではなく、保存先のパスをやり取りするなどの工夫が重要です。

4.2 破綻しない設計を考える

4.2.1 設計の指針

Unity as a Libraryを利用する際は、Unityのみ・Nativeのみで考えるのではなく、アプリ全体でどうあるべきかを検討する必要があります。

様々な解決策がありますが、モバイルアプリにUnityを組み込むような場合は以下が指針となります。

  • なるべくUnityを単なる1つのViewとして扱えるようにする
  • Unityが持つ細かな内部状態(ステート)をFlutterが意識することなく操作できる(カプセル化に近い)
  • 互いに命令し合うのではなく、FlutterがUnityへ「こうなってほしい」を伝える→Unity内部で処理する→結果を通知するの一方向の流れを意識しFlutterを指令塔とする
  • Flutter⇔Unity間でやり取りするメッセージであるJSONをドキュメント化し、それぞれでそのJSONが来た際に正しく動作するかテスト可能な状態を作る

特に最後は重要で、FlutterとUnity両方が揃ってから初めてテストするのではなく、JSONからお互い正しく動作することを確認出来た状態で結合すると手戻りが少なくなります。

4.2.2 アーキテクチャの一例

互いに命令し合うのではなく、FlutterがUnityへ「こうなってほしい」を伝える→Unity内部で処理する→結果を通知するの一方向の流れを意識しFlutterを指令塔とする

これを考えると、Unity側がメッセージを受け取って決まった動作・状態変更をして正しく通知するための確実なフローを考える必要があります。

その回答の1つがReduxで、Reduxは状態変化を一貫した方法で行い堅実な状態変更を行います。

本記事ではReduxの詳しい説明はしませんが、Unity as a Libraryにおいて**『Flutterと連携する状態は手堅く設計し、確実にFlutterへ状態変化が届くようにする』**ことはReduxのデメリットであるボイラープレート(定型)の多さを十分に補います。

Flutter x UnityでReduxを実践した例はこちらの記事が参考になります。この記事ではProtobufを活用にしていますが、JSONでのメッセージングでもReduxは利用できます。

5. 作成したアプリの紹介

3,4の内容を踏まえて冒頭の動画を作成しました。ざっと紹介します。

5.1 Unity側

スマホ側はMiRZA SDKに含まれるコントローラーのTouchPadをそのまま流用しています。
『MiRZAに映像が表示されているか』『ユーザーが準備出来ているか』を完全に自動で判断するよりも、ユーザーに体験スタートの合図を出してもらう方が確実です。
そのため、タッチパッドを長押しするとスタートにしています。

この長押し時のUIやその後のHUDはRiveを使い音と合わせて表示しています。音とモーショングラフィックスを連動させて確実にUnityで再生でき、長押しなどのインタラクションにも対応できるのがやはりRiveの強みと言えるでしょう。

注:HUDはMiRZAの視野角に合わせる必要があります。

今回Riveファイルはマーケットプレイスにある下記をベースに一部改変して利用しています。

Long Press Button Test
Cyber Interface
Simple FUI HUD

5.2 Flutter側

Flutter側はAIを活用し3~4時間程度で2.3のような形にしました。アーキテクチャはこちらを参考にしています。

Unity側でReduxを利用しているからといってFlutterがReduxを採用しなければいけない訳ではありません。むしろUnityが確実に動作する状態付きのView(Flutterで言えばStatefulWidget)であるからこそ、FlutterはFlutterに沿ったアーキテクチャを採用することができます。

今回は大部分がモックですが、外部ログインやAPI通信などはUnityが行うよりもNativeやFlutter側で行う方が簡潔に書けますし処理効率も良いです。Unityをいかに小さく扱えるかが肝とも言えます。

6. まとめ

Unity as a Libraryは固有の課題も多く中上級者向けですが、そのメリットはモバイルアプリのイチ機能としてAR機能があるアプリで非常に有効です。

特に、スマートフォン・グラス両方を扱えるMiRZAにおいては利用の幅が非常に広く、『日常使い出来るアプリ』をUI/UXから開発することが出来ます。

また、Flutter(Native)チームとUnityチームで明確に責務・役割分担を行える点からスケールもしやすく、中規模以上のプロジェクトでも充分に採用可能です。