Flutter Web x Unity as a LibraryでPoCアプリ作った話
はじめに
最近、PoCとしてFC東京のアナリスト向けの支援ツールを作ったんですが、その際にFlutter WebでUnity as a Libraryを組み合わせたチャレンジングな技術スタックで開発を行ったので、所感を述べておこうと思います。
作ったもの
=> 1人称3次元視点(選手視点)を提供するサッカー作戦盤ツール
背景:
サッカーの戦術指導などの現場では、ホワイトボードとマグネットを使って選手配置を再現するというアナログな方法で行われています。
選手からすると3人称2次元視点であり、体感値と乖離が生まれてしまっています。より効果的な戦術指導を行えるために、1人称3次元視点(選手視点)を提供する作戦盤があると良いなと思ったのがきっかけです。
これをやろうとすると、3Dグラフィックス処理が必要になります。
デモ動画
どういったものかイメージがつきやすいと思うので、先に作ったツールのデモ動画を載せておきます。
開発
技術選定の理由
Flutter Web
Flutter Web自体は、よく言われている懸念としてSEO対策や難読化、ブラウザ間での互換性・パフォーマンス差など、に関する問題があると言われており、まだ避けられていることが多い印象があります。
今回に関しては、PoCですし、社内ツール(アクセス制限も有)的なWebアプリケーションの開発になるので、上記懸念はそこまで気にする必要がないというところがあって迷うことなく採用しました。PoCで作る分には困る点はなかったと思います。
ちなみに、PoCを作る場合はネイティブアプリというよりWebアプリでデモをサクッと作るみたいなことがありがちなのかなと思うのですが、その場合Flutter Webで作っておくと、いざ事業化してネイティブアプリを作るとなった時にコードベース使いまわせるので、すごく便利なんじゃないかと思います。
自分はこの意図でも割とFlutter Webでの開発をやることが多いです。
※何より自分はCSSが苦手なのでCSS書かずにUI描けるところが良いです
flutter_unity_widget(Unity as a Library)
Flutterは3Dグラフィクス処理には弱いので、その部分はUnityを使いたいと思いました。
FlutterとUnityを組み合わせて開発する方法としては、"as a Library"か"Add-to-app"があります。その辺はこちらの記事で紹介されています。今回は、使い慣れているFlutterをメインに使いたかったので前者を採用しました。
特に、flutter_unity_widget というライブラリがあることを知ったので、こちらを使って開発することにしました。こちらのライブラリを使うことで、アプリの基本的なUIコンポーネントの開発は従来通りFlutterを使いつつ、3Dグラフィックスの操作はUnityで開発するという、それぞれの得意部分だけを組み合わせて開発ができるようになります。
導入方法
基本的には、flutter_unity_widgetの公式のREADMEのSetup
に従って初期設定を行えば使えるようになると思います。
Unity側の実装が完了したら、ビルドしてFlutter側で扱える状態にします。今回はWeb用にビルドするので、Export Web GL
を実行します。
Flutter側では、flutter_unity_widgetで用意されたUnityWidget
を、他のWidgetと同様に追加したい場所に書くことで、その場所に描画してくれるようになります。
UnityWidget(
onUnityCreated: onUnityCreated,
),
onUnityCreated
は、Unityプレーヤーが初期化された時に実行するイベント(関数)を渡します。
void onUnityCreated(UnityWidgetController controller, pitchState PitchState) async {
_unityWidgetController = controller;
// 選手やボールの初期位置を送信
sendBallPosition(pitchState.ball);
sendPositionsToUnity(pitchState);
// カメラの初期位置を送信
sendCameraPositionToUnity(selectedPlayer);
}
UnityWidgetControllerを引数として受け取る必要があります。それ以外は例えば初期データの送信など、任意の処理が行えます。
Flutter/Unity間の通信方法
FlutterからUnityを操作する
Flutter側で実装されたUIコンポーネントの操作で、Unityプレーヤー側を操作したい場合があると思います。
その場合は、Unity側で受け付け用のメソッドを定義しておき、Flutter側からは_unityWidgetController
を使うことでそのメソッドを呼び出すことができます。例として、先ほどのonUnityCreated
関数内でも呼び出していたsendPositionsToUnity`の実装例を紹介します。
unity側では、例えば以下のようにメソッドを用意しておきます(あくまで疑似コードなのでそのままでは動きません)。メッセージでのやり取りになるのでこのような形になってます。
[System.Serializable]
public class PlayerPositionsWrapper
{
public Player[] positions;
}
[System.Serializable]
public struct Player
{
public float x;
public float y;
public int team_id; // 0: Home, 1: Away
public bool is_keeper; // ゴールキーパーかどうか
public float height; // プレイヤーの身長
public float weight; // プレイヤーの体重
}
public void SetPlayerPositions(string jsonData)
{
PlayerPositionsWrapper wrapper = JsonUtility.FromJson<PlayerPositionsWrapper>(jsonData);
Player[] positions = wrapper.positions;
SpawnPlayers(positions); // プレイヤーオブジェクトの表示
}
ここではメソッドしか載せてないですが、何らかのクラスで定義してgameObject
に紐づけておいてください。
次に、Flutter側です。Flutterでは、_unityWidgetController
のpostMessage
を使ってunity側のメソッドにメッセージを送ります。対象のGameObject、メソッド名、メッセージ(JSON)を引数で指定して実行します。
void sendPositionsToUnity(PitchState pitchState) {
final players = pitchState.players.map((player) {
return {
'x': player.x,
'y': player.y,
'team_id': player.team ? 0 : 1,
'is_keeper': player.isKeeper,
'height': player.height,
'weight': player.weight,
};
}).toList();
String positionsJson = jsonEncode({
'positions': players,
});
_unityWidgetController!.postMessage('PlayerManager', 'SetPlayerPositions', positionsJson);
}
UnityからFlutterを操作する
逆にUnity側からメッセージを受信して、Flutter側で何らかの制御を行いたいような場面もあるかもしれません。その場合は、導入方法で書いたUnityWidget
を宣言する際に、追加でonUnityMessage
という引数を渡す必要があります。
UnityWidget(
onUnityCreated: onUnityCreated,
onUnityMessage: onUnityMessageReceived,
),
onUnityMessage
で渡す関数は、unityからのすべてのメッセージをハンドリングするものになるため、受け取ったメッセージでどのイベント(関数)を実行させるかを判断できるように設計する必要があります。
そのため、やり取りのメッセージに例えばeventName
などのフィールドを含めて以下のように制御することが考えられます。
void onUnityMessageReceived(dynamic message) {
final decodedMessage = jsonDecode(message);
final eventName = decodedMessage['eventName'];
if (eventName == 'cameraDirection') {
List<double> eulerAngle = parseCameraDirectionMessage(decodedMessage['eulerAngle'])
_ref
.read(tacticalBoardSettingsProvider.notifier)
.updateSelectedPlayerDirection(
EulerAngle(
yaw: eulerAngle[1] + 180,
pitch: eulerAngle[0],
roll: eulerAngle[2],
),
);
} else if (eventName == 'hogehoge') {
print("hogehoge")
} else {
throw Exception('Unknown unity event name: $eventName');
}
}
unity側の実装例も一応載せておきます。
[System.Serializable]
public class Message
{
public string eventName;
public string eulerAngle;
}
public class CameraController : MonoBehaviour
{
void Update()
{
if (Input.GetMouseButton(0))
{
// 省略(カメラの更新処理)
// 回転後のカメラの向きをFlutterに送信
var message = new Message
{
eventName = "cameraDirection",
eulerAngle = transform.eulerAngles.ToString(),
};
string jsonMessage = JsonUtility.ToJson(message);
UnityMessageManager.Instance.SendMessageToFlutter(jsonMessage);
}
}
}
困った点
機能面では、実装している中で特に困った点はありませんでした。一方で大きな問題として、flutter_unity_widgetは現在ではwasmビルドに対応していないという点があります。
Flutterはバージョン3.24からwasmに対応したんですが、いくつかのパッケージへの依存を解消しなければいけなくなりました(dart:html
=>package:web
など)。flutter_unity_widgetでは現状その対応がされていません。ただし、3Dで比較的重たいUI描画処理があるので、出来ればwasm対応はしたいところです。
と思っていたところ、ちょうどこの記事を書いているタイミングでwasm対応のPRが出ていました(びっくり)。=> https://github.com/juicycleff/flutter-unity-view-widget/pull/1030
補足としてこちらの検証も行ってみました。
wasm対応と比較
まだ先ほどのPRがマージされるまでは時間がかかるかなと思ったので、先んじて自前でそのブランチからビルドして使ってみました。するとちゃんと--wasm
オプションつけて起動することができました。
デフォルトのビルドモードだと、現在はCanvasKitレンダラーが使用されるのですが、Google ChromeのDevtoolsを使用して両者のパフォーマンスを比較してみました。
デフォルトビルドモード
Wasmビルドモード
比較すると、CPU利用率の部分で結構な差があるように思えます(上の方の黄色いグラフ)。特に、前者ではパフォーマンス低下が発生していることを示す赤いバーがちらほら見受けられます。これが起きているとフレームレートが安定しなかったりにつながります。
また、よく言われている初期ロードの遅さも、グラフからはっきりと読み取れます(最初の方に特に負荷が高く、時間がかかっている。GPU使用率も断続的になっていることも見受けられる)。これもwasmの方ではかなり改善しています。
メモリ(HEAP)に関しても、グラフの見た目だとわかりづらいですが、拡大して右側の数値を見ると値の範囲が異なり、wasmの方がかなり少ない使用量になっていることがわかります。
まとめ
Flutter WebとUnity as a Libraryの事例を書いてみました。
最初はFlutterで3Dグラフィックス処理したい時ってどうすればいいんだろうと思っていましたが、意外と簡単にUnityの導入ができました。
Flutter Webに関しても、今回はPoCでしたが、どんどん便利になってきているので今後実プロダクトで使える場面も増えていくんじゃないかと思っています。
Discussion