📱

Unity PolySpatial 1.1.4を使ってC#とSwiftUIを連携させる

2024/03/05に公開

キービジュアル

概要

PolySpatial 1.1.4が発表されました。アップデートの中で注目なのが、SwiftUIの連携部分が追加された点です。

ただ、visionOSの仕組み上ちょっと癖のある実装となっています。今回は新しく追加されたSwiftUIと連携する方法について解説したいと思います。

動作イメージ

SwiftUIはネイティブ実装

いきなり、Unityで開発するんちゃうんかい、という話ですがSwiftUIを利用するにはSwiftによる実装を行わないとなりません。しかし、visionOSの特徴である磨りガラス風のUIを利用できることを考えると一考の余地はあるでしょう。

Unityのオブジェクトと共存しているSwiftUI
UnityのオブジェクトとSwiftUIが共存している

PolySpatialにおけるSwiftUIの仕組みを見てみると、やや力技と言わざるを得ない実装になっています。実装手順の概要を書くと以下のようなフローで実装していきます。

  1. SwiftUIを表示するシーンを作成(Swiftコード)
  2. (1)のシーンから呼び出されるビューを作成(Swiftコード)
  3. (1), (2)にあるコードとC#を橋渡しするコードの作成(Swiftコード)
  4. C#からネイティブプラグインを呼び出すコードを作成(C#コード)

という流れになります。実に、全体の3/4がSwiftコードなんですね。ただ言い換えればSwiftに慣れている人はかなり簡単にUIを導入することができる、と見ることもできます。

ここでは、簡単な例を元にどうやって実装していくかを細かく見ていきます。ちょっとした連携であればこの記事のコードを少し改変するだけで達成できるでしょう。

Swiftファイルには命名規則がある

まず最初に大事なルールについて解説します。最初、SwiftUIを独自に使おうとした際にどうやっていいか分かりませんでした。(しかも肝心のドキュメントがmissingになっていた・・・)

結論から言うと、Xcodeプロジェクトにコピーされ、シーン構築コードに挿入させるためには ファイル名の命名ルールに従う 必要があります。

具体的には以下のルールに従う必要があります。

  • シーン用コードのファイル名は InjectedScene.swift で終わる必要がある
  • Swiftのアプリサポートコードは SwiftAppSupport フォルダ以下に配置する必要がある

これは、VisionOSBuildProcessor.cs という、ビルド時に差し込まれる処理用コード内に記述されている以下のコードによるものです。

// Capture .swift files that we need to move around later on
var allPlugImporters = PluginImporter.GetAllImporters();
foreach (var importer in allPlugImporters)
{
    if (!importer.GetCompatibleWithPlatform(BuildTarget.VisionOS) || !importer.ShouldIncludeInBuild())
        continue;

    if (importer.assetPath.EndsWith("InjectedScene.swift"))
    {
        m_InjectedScenePaths.Add(importer.assetPath);
    }

    if (importer.assetPath.Contains("/SwiftAppSupport/"))
    {
        m_swiftAppSupportPaths.Add(importer.assetPath);
    }
}

また、PolySpatialのサンプルに含まれている SwiftUISamplePlugin.swift のコメントに以下の記述があります。

// Any file named "...InjectedScene.swift" will be moved to the Unity-VisionOS
// Xcode target (as it must be there in order to be referenced by the App), and
// its static ".scene" member will be added to the App's main Scene. See
// the comments in SwiftUISampleInjectedScene.swift for more information.
//
// Any file that's inside of a "SwiftAppSupport" directory anywhere in its path
// will also be moved to the Unity-VisionOS Xcode target. HelloWorldContentView.swift
// is inside SwiftAppSupport beceause it's needed by the WindowGroup this sample
// adds to provide its content.

このルールに則ってファイルを用意すれば、PolySpatialの仕組みが適切にファイルをコピー、設定してくれます。今回のサンプルでは以下のようにファイルを用意しました。

SwiftUIプラグインのファイルレイアウト

パズルのピースのようなアイコンになっているのはすべてSwiftファイルです。

SwiftUIを実装する

さて、ここからは実際にサンプルコードを元に実際にSwiftUIを実装していきます。今回は冒頭の動画のような、Input Fieldを持つUIを構築していきます。

今回実装するのは以下の3つです。

  1. InputContentView
  2. SwiftUIInputInjectedScene
  3. SwiftUIInputPlugin

(1)はビュー、つまり今回の本題であるUI部分です。(2)はそのビューを表示するシーンを設定します。最後の(3)はUIとC#の連携を実現するインターフェース部分を定義します。

シーンを実装する

上記のリストと前後しますが、まずはシーンを構築する部分から見ていきましょう。コード量も多くないので全文掲載します。

SwiftUIInputInjectedScene.swift
import Foundation
import SwiftUI

struct SwiftUIInputInjectedScene {
    @SceneBuilder
    static var scene: some Scene {
        WindowGroup(id: "InputViewScene") {
            InputContentView()
        }.defaultSize(width: 400.0, height: 400.0)
    }
}

とてもシンプルですね。重要な点は以下の3点です。

  1. ファイル名は必ず InjectedScene.swift で終わる必要がある
  2. staticScene 型の scene プロパティを持つ
  3. scene プロパティが生成するシーン内で必要なビューを呼び出す(今回の例では InputContentView
命名ルールとプロパティルールの理由

前述したコメントにも以下のように記述があります

its static ".scene" member will be added to the App's main Scene.

実際にビルドすると UnityVisionOSSettings.swift 内に以下のように scene プロパティを呼び出すコードが追加されます。

UnityVisionOSSettings.swift
@SceneBuilder
var mainScenePart0: some Scene {
    // 前略
    SwiftUISampleInjectedScene.scene
    SwiftUIInputInjectedScene.scene
}

VisionOSBuildProcesser.cs のコードを見ると以下のように文字列として生成しています。

VisionOSBuildProcesser.cs
foreach (var InjectedScenePath in extraWindowGroups)
{
    var name = Path.GetFileNameWithoutExtension(InjectedScenePath);
    sceneContent.Add($"\n       {name}.scene");
}

そのため、厳密に型などをチェックしているわけではありません。そもそもSwiftのコードはコンパイル対象ではないのでこのあたりは注意する必要があります。もし間違えているとXcodeプロジェクトを開いた際にエラーになってしまいます。

ビューを実装する

次はビューの実装を見てみましょう。

InputContentView.swift
import Foundation
import SwiftUI
import UnityFramework

struct InputContentView: View {
    @State var message: String = ""
    @FocusState var isFocused: Bool
    
    var body: some View {
        VStack {
            Text("Please input your message here.")
            
            TextField("Input here...", text: $message)
                .padding(EdgeInsets(top: 0, leading: 30, bottom: 0, trailing: 30))
                .textFieldStyle(.roundedBorder)
                .padding()
                .focused($isFocused)
            
            Button("Send") {
                callCSharpInputCallback(self.message)
            }
        }
        .onAppear {
            callCSharpInputCallback("appeared")
        }
    }
}

SwiftUIでのUI構築フローとまったく変わりはありません。SwiftUIに慣れ親しんだ人なら自由にUIを作成することができます。

今回のコードを少しだけ解説しておきます。

SwiftUIでは View プロトコルに準拠した構造体を定義しそれを利用します。今回は InputContentView がその役割を担います。また、 View プロトコルは body プロパティを要求するためビュー構築の処理をここに記述します。今回の例では VStack として縦にスタックするレイアウトに対して TextTextFieldButton の3つを配置しています。

TextField に入力された内容を、ボタン押下でC#側に伝える、というのが今回の実装となります。

ボタン押下時に呼ばれている callCSharpInputCallback については後述します。

C#との連携部分の実装

最後に、C#と連携する部分のSwiftコードを見ていきましょう。

SwiftUIInputPlugin.swift
import Foundation
import SwiftUI

typealias InputCallbackDelegateType = @convention(c) (UnsafePointer<CChar>) -> Void

var sInputCallbackDelegate: InputCallbackDelegateType? = nil

public func callCSharpInputCallback(_ str: String)
{
    guard let callback = sInputCallbackDelegate else {
        return
    }

    str.withCString {
        callback($0)
    }
}

@_cdecl("SetNativeInputCallback")
func setNativeInputCallback(_ delegate: InputCallbackDelegateType)
{
    print("############ SET NATIVE CALLBACK")
    sInputCallbackDelegate = delegate
}

@_cdecl("OpenSwiftUIInputWindow")
func openSwiftUIInputWindow(_ cname: UnsafePointer<CChar>)
{
    let openWindow = EnvironmentValues().openWindow

    let name = String(cString: cname)
    print("############ OPEN WINDOW \(name)")
    openWindow(id: name)
}

@_cdecl("CloseSwiftUIInputWindow")
func closeSwiftUIInputWindow(_ cname: UnsafePointer<CChar>)
{
    let dismissWindow = EnvironmentValues().dismissWindow

    let name = String(cString: cname)
    print("############ CLOSE WINDOW \(name)")
    dismissWindow(id: name)
}

いわゆるマネージドコードとアンマネージドコード間の連携となるため、ここは少し処理が複雑です。

ここで行っていることをざっくり概観しておくと、

  1. C#側の関数ポインタを保持
  2. C#側から呼ばれる処理の宣言・定義
  3. C#側コールバックの呼び出し

ということをやっています。特に、SwiftとC#を跨ぐ部分については細かな取り決めに従う必要がるために複雑になっています。

C#側の関数ポインタの保持

上から順番に見ていきましょう。ポインタの保持、言い換えるとC#のデリゲートのような処理を行っているのが以下です。

SwiftUIInputPlugin.swift
typealias InputCallbackDelegateType = @convention(c) (UnsafePointer<CChar>) -> Void

var sInputCallbackDelegate: InputCallbackDelegateType? = nil

public func callCSharpInputCallback(_ str: String)
{
    guard let callback = sInputCallbackDelegate else {
        return
    }

    str.withCString {
        callback($0)
    }
}

@_cdecl("SetNativeInputCallback")
func setNativeInputCallback(_ delegate: InputCallbackDelegateType)
{
    print("############ SET NATIVE CALLBACK")
    sInputCallbackDelegate = delegate
}

typealias によって型のエイリアスを作成します。その際、 @convention(c) を指定することでC言語の呼出規約に従うように明記します。ここが「橋渡し」をする上で大事な部分です。

なぜこれが必要かをざっくり説明すると、機械語に変換されたあとは関数などの抽象的な概念から、より直接的な記述へ変換されます。その際に、引数の順番をどうするか、なども様々な規約があり、これを「呼出規約」と呼んでいます。

呼出規約とは

呼出規約をもう少し詳細に解説します。

Wikipediaから引用すると以下のように書かれています。

呼出規約(よびだしきやく)ないし呼出慣例(よびだしかんれい)(英: calling convention)は、コンピュータの命令セットアーキテクチャごとに取り決められるABIの一部で、サブルーチンが呼出される際に従わねばならない制限などの標準である。名前修飾について、データを渡す「実引数」、戻るべきアドレスである「リターンアドレス」、データを戻す「返戻値」などを、スタックなどに対してどのように格納するのか、また各レジスタを、呼び出し側とサブルーチンのどちらの側が保存するか、等といった取決めの集まりである。言語が同じでも、分割コンパイルされリンカでリンクされる相互のプロシージャ間では、呼出し呼出されるならば同一の呼出規約に従っていなければならない。一方で、違う言語の間でも、同一の呼出規約を経由して相互にプロシージャを呼出すこともできる。

また今回説明した cdecl では以下のように説明されています。

インテルx86ベースのシステム上のC/C++では cdecl 呼出規約が使われることが多い。cdeclでは関数への引数は右から左の順でスタックに積まれる。関数の戻り値は EAX(x86のレジスタの一つ)に格納される。呼び出された側の関数ではEAX, ECX, EDXのレジスタの元の値を保存することなく使用してよい。呼び出し側の関数では必要ならば呼び出す前にそれらのレジスタをスタック上などに保存する。スタックポインタの処理は呼び出し側で行う。
例えば、以下のCプログラムの関数呼び出しは、

int function(int, int, int);
int a, b, c, x;
...
x = function(a, b, c);

以下のような機械語を生成する(MASMにおけるx86アセンブリ言語で記述する)。

push c
push b
push a
call function
add esp, 12 ;スタック上の引数を除去
mov x, eax

今回の例では、こうした規約に準拠させる必要があるためにそれらを明示している、というわけです。

続く setNativeInputCallback が、ポインタ(デリゲート)の保持をしている箇所ですね。コールバックを返したい場合に、 callCSharpInputCallback を呼び出し、保持したデリゲートを実行します。

前述の、ボタン押下時に呼び出していたのがまさにこの関数ですね。

C#からの呼び出し

C#からSwiftコードを呼び出せるようにしているのが以下の部分です。

SwiftUIInputPlugin.swift
@_cdecl("OpenSwiftUIInputWindow")
func openSwiftUIInputWindow(_ cname: UnsafePointer<CChar>)
{
    let openWindow = EnvironmentValues().openWindow

    let name = String(cString: cname)
    print("############ OPEN WINDOW \(name)")
    openWindow(id: name)
}

@_cdecl("CloseSwiftUIInputWindow")
func closeSwiftUIInputWindow(_ cname: UnsafePointer<CChar>)
{
    let dismissWindow = EnvironmentValues().dismissWindow

    let name = String(cString: cname)
    print("############ CLOSE WINDOW \(name)")
    dismissWindow(id: name)
}

@_cdecl 部分は前述の呼出規約 cdecl そのままですね。ただ、Swiftにおいて @_ で始まる属性は非公式のものらしく、今後の仕様変更などで記述方法などが変わる可能性があることに注意が必要です。PolySpatialのサンプルコードのコメントにもその旨が記載されています。

また、 @_cdecl の引数にしている文字列は、C#側からどういう名前で呼ばれるかを規定しています。このあたりは、言語間で命名ルールが違ったりするために定義と分けられるようにしているのだと思います。

引数になっている UnsafePointer<CChar> は、C#側からの文字列を受け取るためのものです。文字列はよく使われるものであるものの、言語間の実装には差異があり、 Int などに比べると簡単に扱えるものではありません。メモリ割り当ての都合などもあります。そのため、そうしたことを回避するためのケアが必要になり、その結果文字列( CChar )のポインタとして受け取ることになっています。

Swiftの String への変換はシンプルにコンストラクタにポインタを渡すだけなので利用は簡単です。

C#側の実装

次はC#の実装を見ていきましょう。

using System.Collections.Generic;
using System.Runtime.InteropServices;
using AOT;
using PolySpatial.Samples;
using TMPro;
using UnityEngine;

public class SwiftUiBridge : MonoBehaviour
{
    private delegate void CallbackDelegate(string command);

    [SerializeField] private SpatialUIButton _button;
    [SerializeField] private TMP_Text _text;

    private bool _swiftUIWindowOpen = false;

    private void OnEnable()
    {
        _button.WasPressed += WasPressed;
        SetNativeInputCallback(CallbackFromNative);
    }

    private void OnDisable()
    {
        SetNativeInputCallback(null);
        CloseSwiftUIInputWindow("HelloWorld");
    }

    private void WasPressed(string buttonText, MeshRenderer meshrenderer)
    {
        Debug.Log("----------> Button was pressed: " + buttonText);

        Toggle();
    }

    private void Toggle()
    {
        if (_swiftUIWindowOpen)
        {
            CloseSwiftUIInputWindow("InputViewScene");
            _swiftUIWindowOpen = false;
        }
        else
        {
            OpenSwiftUIInputWindow("InputViewScene");
            _swiftUIWindowOpen = true;
        }
    }

    [MonoPInvokeCallback(typeof(CallbackDelegate))]
    private static void CallbackFromNative(string message)
    {
        Debug.Log("Callback from native: " + message);

        SwiftUiBridge self = Object.FindFirstObjectByType<SwiftUiBridge>();

        if (message == "closed")
        {
            self._swiftUIWindowOpen = false;
        }
        else
        {
            self._text.text = message;
        }
    }

#if UNITY_VISIONOS && !UNITY_EDITOR
        [DllImport("__Internal")]
        private static extern void SetNativeInputCallback(CallbackDelegate callback);

        [DllImport("__Internal")]
        private static extern void OpenSwiftUIInputWindow(string name);

        [DllImport("__Internal")]
        private static extern void CloseSwiftUIInputWindow(string name);
#else
    static void SetNativeInputCallback(CallbackDelegate callback)
    {
    }

    static void OpenSwiftUIInputWindow(string name)
    {
    }

    static void CloseSwiftUIInputWindow(string name)
    {
    }
#endif
}

注意点は以下の4点です。

  • delegateを宣言する
  • DllImport を用いて外部で定義されていることを宣言する
  • [MonoPInvokeCallback(typeof(CallbackDelegate))] 属性を付与してアンマネージドコードから呼び出せるようにする
  • delegateに指定するメソッドは static にする

大事な点に絞って説明していきます。

DllImportで外部の実装を宣言する

以下のように、Swift側で実装されている部分については DllImport を使って、これが外部のDLLで定義されていることを通知します。

[DllImport("__Internal")]
private static extern void SetNativeInputCallback(CallbackDelegate callback);

[DllImport("__Internal")]
private static extern void OpenSwiftUIInputWindow(string name);

[DllImport("__Internal")]
private static extern void CloseSwiftUIInputWindow(string name);

Swift側の実装で @_cdecl("") と設定したのと同名のメソッド名になっていることが分かります。また、 static extern を指定しておく必要があります。こうしておくとリンカがリンク時に該当の実装にリンクしてくれるというわけですね。

MonoPInvokeCallbackを指定する

[MonoPInvokeCallback(System.Type type)] を指定することで、P/Invoke APIによって適切にアンマネージドコードとやり取りができるようにしてくれます。

P/Invokeとは

P/InvokeはPlatform Invokeの略です。ドキュメントの説明を引用すると、

P/Invokeは、アンマネージドライブラリ内の構造体、コールバック、および関数をマネージドコードからアクセスできるようにするテクノロジです。P/Invoke APIのほとんどは System と System.Runtime.InteropServices の2つの名前空間に含まれます。これら2つの名前空間を使用すると、ネイティブコンポーネントと通信する方法を記述するツールを利用できます。

つまり、ネイティブ側とやり取りするための規約(API)ということですね。まさにネイティブ側から呼び出されるように設定するため、MonoPInvokeCallback 属性を付けているというわけです。

https://learn.microsoft.com/ja-jp/dotnet/standard/native-interop/pinvoke

IL2CPPの制約により、staticメソッドにする必要がある

[MonoPInvokeCallback(typeof(CallbackDelegate))]
private static void CallbackFromNative(string message) {}

今回のコールバックに指定するメソッドは static となっています。これはIL2CPPの制約で、インスタンスメソッドを指定することができないためです。そのため、呼び出し元のインスタンスを探すなど、少し手順が増えてしまうのが難点です。(ただ、今回は FindObjectOfType<T> を使って簡単に見つけるだけにとどめています)

まとめ

実装の解説は以上です。SwiftUIとの連携は手順が多いのでやや複雑ですが、仕組みさえ分かってしまえば、ここから追加・修正することは比較的容易でしょう。特に、SwiftUIでないと実現できないことだけに絞って実装することでコストも抑えられると思います。

今回は、現状PolySpatial単体では文字入力に対応していないために、SwiftUIを介して入力するというものを実現しました。

PolySpatialはまだまだ発展途上ではありますが、Unityで作った資産をApple Vision Proに持っていけるのはとても力強いのでぜひ活用していきたいですね。

MESONからのお知らせ

エンジニア絶賛募集中!

MESONではUnityエンジニアを絶賛募集中です! XRのプロジェクトに関わってみたい! 開発したい! という方はぜひご応募ください!

https://careers.meson.tokyo/

体験会・勉強会実施のお知らせ

MESONでは、オフィスなどでのVision Pro体験会実施や企業向けの研修プログラムの提供を行っております。今回紹介したアプリもインストールされてますので、興味のある方はぜひお問い合わせフォームよりご連絡お待ちしております。

https://meson.typeform.com/to/q4omFL

企業のご担当者の方

Apple Vision Proのエントリー勉強会プログラム「Ready for Vision Pro」を提供開始しております。興味のある方は是非お気軽にお問い合わせ下さい。

https://prtimes.jp/main/html/rd/p/000000056.000032228.html

書いた人

えど

比留間 和也(あだな:えど)

カヤック時代にWEBエンジニアとしてリーダーを務め、その後VRに出会いコロプラに転職。 コロプラでは仮想現実チームにてXRコンテンツ開発に携わる。 DAYDREAM向けゲーム「NYORO THE SNAKE & SEVEN ISLANDS」をリリース。その後、ARに惹かれてMESONに入社。 MESONではARエンジニアとして活躍中。
またプライベートでもAR/VRの開発をしており、インディー部門でTGSに出展など公私関わらずAR/VRコンテンツ制作に精を出す。プライベートな時間でも開発しているように、新しいことを学ぶことが趣味で、最近は英語を学んでいる。

GitHub / Twitter

MESON Works

MESONの制作実績一覧もあります。ご興味ある方はぜひ見てみてください。

MESON Works

Discussion