- 自作パッケージを作る
- パッケージをビルドする
- Swift側の実装を利用する
- Swiftのクラスを利用する
- Swift側の文字列(String)を扱う
- ネイティブ側からC#の関数を呼ぶコールバックの実装
- 依存のあるパッケージについて
- 注意点メモ
- 応用編
- まとめ
最近はAI関連のニュースが毎日のように飛び込んできますね。MESONでもXR x AIという形でAIにも注力しています。
ChatGPTを筆頭に、LLMはとんでもないスピードで発展しています。今ではローカルで、しかもモバイル上で動くLLMなんかも出てきたりしています。
今回は、iOSでパッケージを利用する方法についてまとめたいと思います。AIの話をしておいてなんでやねん、という感じですがiOS向けに利用できるLLMの環境としてllama.cppがあります。これ以外にもありますが、こうしたものを利用するにもUnityで扱える状態を作らないとならないので、そのために色々調査したものを備忘録としてまとめました。
ちなみにllama.cppのSwift実装はリポジトリの examples
内にあります。
■ サンプルプロジェクト
このブログ内で紹介しているサンプルプロジェクトをGitHubに上げてあるので、動作がうまくいかない場合などに参考にしてください。
自作パッケージを作る
ビルドしてUnityで利用するためのSwiftパッケージを作成するところから始めましょう。
パッケージ用に初期化する
まず、パッケージとなるディレクトリを作成し、以下のコマンドを使って初期化します。
$ mkdir SwiftPlugin $ cd SwiftPlugin $ swift package init --type library --name <PACKAGE_NAME> # 今回はSwiftPlugin
上記コマンドを実行すると以下のようにベースとなるファイルなどが生成されます。
Creating library package: SwiftPlugin Creating Package.swift Creating .gitignore Creating Sources/ Creating Sources/SwiftPlugin/SwiftPlugin.swift Creating Tests/ Creating Tests/SwiftPluginTests/ Creating Tests/SwiftPluginTests/SwiftPluginTests.swift
ここで生成された Package.swift
をダブルクリックで開くとXcodeが起動し、パッケージとして認識されていることが確認できます。
今回はサンプルのためごく簡単なメソッドだけを実装してみます。
以下のコードを、デフォルトで生成される Sources/SwiftPlugin.swift
に追加します。
@_cdecl("calc") public func calc(a: Int32, b: Int32) -> Int32 { return a + b }
定義の際の大事な点は関数に @_cdecl("関数名")
属性を付けることです。
cdeclについて
cdeclとは「呼び出し規約」と呼ばれるものです。呼び出し規約とは、コンピュータの命令セットアーキテクチャごとに取り決めとして定義されるもので、ABI(Application Binary Interface)の一部です。
Wikipediaから引用させてもらうと以下のように説明されています。
インテルx86ベースのシステム上のC/C++では cdecl 呼出規約が使われることが多い。cdeclでは関数への引数は右から左の順でスタックに積まれる。関数の戻り値は EAX(x86のレジスタの一つ)に格納される。呼び出された側の関数ではEAX, ECX, EDXのレジスタの元の値を保存することなく使用してよい。呼び出し側の関数では必要ならば呼び出す前にそれらのレジスタをスタック上などに保存する。スタックポインタの処理は呼び出し側で行う。
最終的に機械語に翻訳されたのち、関数呼び出しというのは「スタックに値を保持したあと、指定の場所に処理をジャンプさせる」という、かなり具体的な処理に変換されます。バイナリインターフェースの名前の通り、その際の「どうやってスタックに積むのか」などの取り決めを行うのがこの呼び出し規約です。
例えば、呼び出し側でスタックへ積む順番を間違えてしまうなどすると、呼び出される側で異なる位置の値を読み込んでしまうことになります。結果的に、引数が適切に渡らないなどの問題につながります。
今回はネイティブプラグインとして利用するため、この規約がなにに従っているのかを知る必要があるために @_cdecl
という属性を付けている、というわけですね。C#のところでも解説していますが、C#側も「どう呼び出すか」というのを cdecl
を指定して宣言しています。
パッケージをビルドする
あまり意味のない実装ですが、分かりやすさ優先です。さて、これをビルドしていきましょう。
Unityでの開発を想定しているため、iOS向けのビルドだけでなくmacOS向けのビルドも行い、Editor上で動くようにもしておきます。
macOS向けにビルドする
今回は共有ライブラリとしてビルドするため、 Package.swift
を以下のように修正し、 type
を .dynamic
にしておきます。
// swift-tools-version: 5.9 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription let package = Package( name: "SwiftPlugin", products: [ // Products define the executables and libraries a package produces, making them visible to other packages. .library( name: "SwiftPlugin", // typeを.dynamicにする type: .dynamic, targets: ["SwiftPlugin"]), ], targets: [ // Targets are the basic building blocks of a package, defining a module or a test suite. // Targets can depend on other targets in this package and products from dependencies. .target( name: "SwiftPlugin"), .testTarget( name: "SwiftPluginTests", dependencies: ["SwiftPlugin"]), ] )
macOS向けには以下のコマンドを利用してビルドします。コマンドを実行するのは Package.swift
ファイルがあるフォルダ内です。
$ swift build -c release --arch arm64 --arch x86_64
実行すると .build
フォルダが生成されその中にライブラリが入っています。(Finderで見ると .
付きフォルダのため見えない場合があります)
生成された中に .dylib
の拡張子のものがあるのでこれを利用します。このファイルをUnityプロジェクトにインポートしてください。
macOS向けのビルドは以上です。
iOS向けにビルドする
次に、iOS向けにビルドしていきましょう。
iOS向けには xcodebuild
コマンドを利用します。
$ xcodebuild -scheme <PACKAGE_NAME> -configuration Release -sdk iphoneos -destination generic/platform=iOS -derivedDataPath ./Build/Framework build
※ 今回のシンプルな状態でもTestターゲット向けに少しエラーが発生してしまいますが、Framework自体は生成されており、さらにちゃんと実機で動作する形になっています。なぜエラーが出るのか、どう解消したらいいのかについては追って調査予定です。
生成された SwiftPlugin.framework
フォルダをUnityにインポートします。(フォルダごとです)
Unity上ではしっかりライブラリとして認識されます。(アイコンが変わる)
iOS向けライブラリはEmbed設定をする
iOS向けライブラリは Add to Embedded Binary
のチェックを入れておく必要があります。
以上でiOS側の設定も完了です。
Swift側の実装を利用する
続いて、Swift側で定義した処理(ネイティブ側の処理)をC#から利用する方法について見ていきます。
まずはコード全文を載せます。
using System.Runtime.InteropServices; using TMPro; using UnityEngine; public class SwiftPluginTest : MonoBehaviour { #if UNITY_EDITOR_OSX private const string DLL_NAME = "libSwiftPlugin"; #elif UNITY_IOS private const string DLL_NAME = "__Internal"; #endif #if UNITY_EDITOR_OSX || UNITY_IOS [DllImport(DLL_NAME, CallingConvention = CallingConvention.Cdecl)] internal static extern int calc(int a, int b); [SerializeField] private TMP_Text _text; private void Start() { int result = calc(10, 20); Debug.Log(result); _text.text = result.ToString(); } #endif }
ネイティブプラグインを利用する場合は DllImport
属性を利用し、外部で関数が定義されていることを伝えます。また、Swift側で @_cdelc
呼び出し規約を指定しているので、C#側も CallingConvention.Cdecl
を指定します。
詳細についてはUnityのネイティブプラグインの作り方などを参照してください。
ここでの注意点は、macOSの場合はプラグイン名(ファイル名)を指定する必要がありますが、iOSの場合は __Internal
を指定する必要がある点です。
関数については宣言だけを行っておけば、実行時はライブラリの関数を参照するようにコンパイルされます。
利用については通常のC#のメソッドと同様に呼び出すだけでOKです。
int result = calc(10, 20);
実行すると、確かにライブラリ側の処理が実行されていることが分かります。(ちゃんと 30
とログが表示されている)
これで、Swift側で実装した内容をC#から(Unityから)呼び出すことができました。iOS用のライブラリも追加してあるので実機にビルドしてもちゃんと動作します。
Swiftのクラスを利用する
さて、関数を単体で定義してそれを呼び出すだけでは、ほとんどの場合そもそもC#だけでも行える可能性が高いです。実プロジェクトでは様々なライブラリの依存や、クラスの利用などが想定されます。
次では、Swift側で定義したクラスをC#側から利用する方法について見ていきます。
まずは以下のようにクラスを定義します。
public class NativeUtility { public func add(a: Int32, b: Int32) -> Int32 { a + b } public func sub(a: Int32, b: Int32) -> Int32 { a - b } }
ただ、C#側からは直接、Swiftのクラス情報にアクセスすることができません。そのため、以下のように、クラス情報を扱うための関数を別途定義します。
@_cdecl("create_instance") public func create_instance() -> UnsafeMutableRawPointer { let utility: NativeUtility = NativeUtility() return Unmanaged.passRetained(utility).toOpaque() } @_cdecl("use_utility_add") public func use_utility_add(_ pointer: UnsafeMutableRawPointer, _ a: Int32, _ b: Int32) -> Int32 { let instance: NativeUtility = Unmanaged<NativeUtility>.fromOpaque(pointer).takeUnretainedValue() return instance.add(a: a, b: b) } @_cdecl("use_utility_sub") public func use_utility_sub(_ pointer: UnsafeMutableRawPointer, _ a: Int32, _ b: Int32) -> Int32 { let instance: NativeUtility = Unmanaged<NativeUtility>.fromOpaque(pointer).takeUnretainedValue() return instance.sub(a: a, b: b) }
※ 今回はサンプルのため、インスタンスの解放などの処理は書いていませんが、実際のプロジェクトで利用する場合は、インスタンスの破棄などの処理も必要になるでしょう。
なにをしているかざっくり解説すると、 @_cdecl
属性によって extern C
のように外部に関数名を公開します。そしてインスタンスの生成とそれを利用する関数を定義しています。
C#から関数へはアクセスできるため、Swift側のクラス情報を「ポインタ」という形でリレーすることで処理を実行するようにしている、というわけです。
なのでこれを利用する場合は create_instance
関数でインスタンスのポインタを生成し、 use_utility_***
でインスタンスを渡して実行している、というわけですね。( use_utility_***
となっていますが、これは決められた名前ではなく任意の名前を付けることができます。念のため)
ポインタを利用する
関数の内部がややごちゃごちゃしていますが、これはSwift側の参照カウンタなどの処理を活用するための処理です。ひとつずつ見ていきましょう。
まずはインスタンスの生成から。
@_cdecl("create_instance") public func create_instance() -> UnsafeMutableRawPointer { let utility: NativeUtility = NativeUtility() return Unmanaged.passRetained(utility).toOpaque() }
インスタンス生成関数の戻り値は UnsafeMutableRawPointer
です。
※ Mutableなので UnsafeRawPointer
に変換して利用するほうがより安全かもしれませんが、さらに処理が増えてしまうのでシンプルさ重視で書いています。
まずは普通にインスタンスを生成し、それを Unmanage.passRetained().toOpaque()
で UnsafeMutableRawPointer
に変換して返しています。
次に、実際にクラス(インスタンス)を利用する関数です。
@_cdecl("use_utility_add") public func use_utility_add(_ pointer: UnsafeMutableRawPointer, _ a: Int32, _ b: Int32) -> Int32 { let instance: NativeUtility = Unmanaged<NativeUtility>.fromOpaque(pointer).takeUnretainedValue() return instance.add(a: a, b: b) }
第一引数に、先ほど生成したインスタンスをポインタとして渡していますね。ポインタを経由していると書いたのはこれが理由です。そして関数内でポインタから元のクラスのインスタンスに変換した上で、利用したいメソッドを実行しているわけです。
Unmanaged<CLASS_NAME>.fromOpaque(<POINTER>).takeUnretainedValue()
によってインスタンスを得ています。
ちなみに takeUnretainedValue
は、インスタンスを得る際に参照カウンタを増加させずに取得する方法です。いわゆるweak pointer的な感じでしょうか。(あまりSwiftに詳しくないので想像ですが)
こうすることで、参照カウンタを増やさずに機能だけを利用することができます。逆に、参照カウンタを得たい場合は takeRetainedValue()
を使います。
インスタンスが取得できれば、あとは利用したいメソッドを実行するだけですね。
Swift側のクラスを利用する方法で、Swift側の準備は以上です。
ちなみに込み入ったことをやろうとするとラッパーが増えていくことになりますが、ネイティブな機能を呼び出したいケースというのは局所的であることが多いと思います。もし複雑なことをやりたい場合は、Swift側でさらにラッパークラスを使って、そのクラスの中に処理を詰め込み、C#からは処理の開始などだけを依頼するような形がいいと思います。
C#から呼び出す
Swift側の準備が終わったので、最後にC#からどう利用するかを見ていきましょう。といっても、基本的な作法は前述のものと変わりません。違いは IntPtr
を使う点くらいです。
以下は、新しく追記した部分の抜粋です。
[DllImport(DLL_NAME, CallingConvention = CallingConvention.Cdecl)] internal static extern IntPtr create_instance(); [DllImport(DLL_NAME, CallingConvention = CallingConvention.Cdecl)] internal static extern int use_utility_add(IntPtr instance, int a, int b); [DllImport(DLL_NAME, CallingConvention = CallingConvention.Cdecl)] internal static extern int use_utility_sub(IntPtr instance, int a, int b); private void Start() { // 中略 { IntPtr instance = create_instance(); int result2 = use_utility_add(instance, 25, 32); Debug.Log(result2); _text2.text = result2.ToString(); } }
最初のときと同じように DllImport
属性を付与して関数を宣言しています。そして利用する部分は、まずインスタンスを生成し、それをポインタ( IntPtr
)として受け取り、それを使って対象のメソッドを呼び出す、という処理になっています。
しっかりと計算されているのが分かります。
以上でSwiftを利用する方法の解説は終わりです。
以下はもう少し発展した使い方などを書いていきます。
Swift側の文字列(String)を扱う
さて、上のサンプルは Int32
型、つまり整数のみを扱っていたのでやり取りはそこまで複雑ではありませんでした。整数などはシンプルなビット配列なのでネイティブ側とのやり取りもシンプルになります。
しかし、文字列など少し込み入った情報をやり取りする場合はそうもいきません。以下は、Swift側で生成した文字列をC#側で扱う方法について見ていきましょう。
Swift側の定義
まずはSwift側での定義を見てみます。
import Foundation // strdupを使うのにこれが必要 public class NativeUtility { // 中略 public func version() -> String { "1.0.0" } } @_cdecl("use_utility_version") public func use_utility_version(_ pointer: UnsafeMutableRawPointer) -> UnsafeMutablePointer<CChar> { let instance: NativeUtility = Unmanaged<NativeUtility>.fromOpaque(pointer).takeUnretainedValue() let version = instance.version() return strdup(version) }
Swift側の文字列を UnsafeMutablePointer<CChar>
型に変換しているのがポイントです。これまたポインタ経由でやり取りするわけですね。ポインタ万能。
ちなみに文字列をポインタに変換するには strdup()
関数を使います。これを利用するためには import Foundation
とする必要がある点に注意です。
C#で文字列を受け取る
ではC#でどう文字列を受け取るかを見ていきましょう。
[DllImport(DLL_NAME, CallingConvention = CallingConvention.Cdecl)] internal static extern IntPtr use_utility_version(IntPtr instance); private void Start() { // 中略 IntPtr instance = create_instance(); int result2 = use_utility_add(instance, 25, 32); Debug.Log(result2); _text2.text = result2.ToString(); IntPtr strPtr = use_utility_version(instance); string result3 = Marshal.PtrToStringAnsi(strPtr); Debug.Log(result3); _text3.text = result3; Marshal.FreeHGlobal(strPtr); }
例に漏れず関数呼び出しの宣言を追加します。戻り値が IntPtr
になっている点に注目です。文字列そのものをやり取りするのではなく、いったんポインタを経由するのはインスタンスのやり取りと同じですね。文字列のインスタンスをやり取りする、と考えると分かりやすいでしょう。
そして取得した文字列ポインタをC#の文字列に変換します。変換するには Marshal.PtrToStringAnsi(<POINTER>)
を使います。戻り値はC#の string
なのであとはそのまま利用するだけですね。
注意点として、文字列を使い終わったら Marshal.FreeHGlobal(<POINTER>)
を実行して解放してやる必要があります。
C#から文字列を送る
ではC#から文字列を送る場合はどうでしょうか?
まずはSwiftの定義から見ていきましょう。
public class NativeUtility { // 中略 public func stringDecoration(str: String) -> String { "Decorated[\(str)]" } } @_cdecl("use_utility_decorate") public func use_utility_decorate(_ pointer: UnsafeMutableRawPointer, _ text: UnsafePointer<CChar>) -> UnsafeMutablePointer<CChar> { let instance: NativeUtility = Unmanaged<NativeUtility>.fromOpaque(pointer).takeUnretainedValue() let string = String(cString: text) let result = instance.stringDecoration(str: string) return strdup(result) }
文字列を返す場合は UnsafeMutablePointer<CChar>
型でしたが、受け取る場合は UnsafePointer<CChar>
型で受け取ります。
Swiftの String
型への変換は String
のコンストラクタにポインタを渡すだけですね。あとは普通に String
として利用するだけです。
今回のサンプルでは加工した文字列を返しているので、前述の、C#へ文字列を返す方法をそのまま利用しています。
C#とSwift間で整数(プリミティブ)、文字列、インスタンスのやり取りの方法の解説は以上です。
最後に、ネイティブ側からC#のコードを呼び出すコールバックについて解説します。少しだけ準備が増えます。
ネイティブ側からC#の関数を呼ぶコールバックの実装
今自分が実装を試みているのが、Swift側で非同期処理があるパターンです。そのため、処理が終わってからC#側の処理を呼び出さないとなりません。当然、C#側の async / await
は利用できないのでコールバックという形で結果を受け取ります。
まず、SwiftとC#双方でコールバック(関数ポインタ)の型を宣言する必要があります。順番に見ていきましょう。
Swift側でコールバックの型を宣言
まずはSwift側から見てみましょう。
public typealias completion_callback = @convention(c) (Int32) -> Void @_cdecl("async_test") public func async_test(_ completion: completion_callback) -> Void { DispatchQueue.global().async { completion(35) } }
今回はネイティブ側から整数を送るだけのものを宣言しています。そしてそれを受け取る関数を定義し、処理が終わったのちに関数として呼び出しています。
C#側でもコールバックの型を宣言
次にC#側を見てみましょう。
[UnmanagedFunctionPointer(CallingConvention.Cdecl)] private delegate void CompletionCallback(int result); [DllImport(kLibName, CallingConvention = CallingConvention.Cdecl)] private static extern void async_test(IntPtr callback); private CompletionCallback _completionCallback; private GCHandle _gcHandle; private void Callback(int result) { Debug.Log(result); // コールバックを渡す関数を実行する前に `GCHandle` を取得しているので、それを解放する。 _gcHandle.Free(); } private void CallWithCallback() { _completionCallback = Callback; IntPtr pointer = Marshal.GetFunctionPointerForDelegate(_completionCallback); _gcHandle = GCHandle.Alloc(_completionCallback); async_test(pointer); }
C#側の準備はちょっと多めになります。まず、コールバックの型を宣言しています。宣言の際に UnmanagedFunctionPointer
属性を付与しています。
そして delegate
を保持する変数を準備し、さらにガベージコレクションされないように GCHandle
の変数も用意します。あとはコールバックそのものを定義していますね。
こうして準備したものを用いてコールバックを渡して実行します。
delegate
を IntPtr
に変換してから渡しているのがポイントです。変換には Marshal.GetFunctionPointerForDelegate(<DELEGATE>)
を使います。
また GCHandle
を取得したのちにネイティブ側の関数を呼び出しています。
最後に、コールバックが呼ばれたら確保したハンドルを解放して終わりです。
文字列やクラスのインスタンスと同様、ネイティブ側とのこうしたやり取りには基本的にポインタを利用する、と覚えておくといいでしょう。
GCHandleが必要な理由
なぜこの GCHandle.Alloc
を行っているのでしょうか。これはマネージドコード特有の問題です。C#は定期的にガベージコレクタによってメモリの解放が行われます。この際、場合によってはメモリの最適化のためにポインタの位置が変更される可能性があります。もし非同期処理の実行後にコールバック呼び出しのために、変更前のアドレスにアクセスしてしまうと当然、クラッシュの要因となってしまいます。
これを避けるために GCHandle
を利用しているというわけです。
最後に、パッケージ間の依存について話をして今回の記事を終わりにしたいと思います。
依存のあるパッケージについて
今回のサンプルではひとつのパッケージのみを作成しました。しかし、パッケージには依存関係を定義することができます。
例として新しく SwiftPluginWrapper
というパッケージを作成し、依存関係を作ってみます。
コマンドで生成された Package.swift
の Package
に dependencies
を追加しリソースの場所を指定します。そして targets
にも dependencies
を追加してパッケージ名を指定します。
// swift-tools-version: 5.9 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription let package = Package( name: "SwiftPluginWrapper", products: [ // Products define the executables and libraries a package produces, making them visible to other packages. .library( name: "SwiftPluginWrapper", type: .dynamic, targets: ["SwiftPluginWrapper"]), ], dependencies: [ .package(path: "../SwiftPlugin"), // GitHub上のパッケージなども指定することができる // .package(url: "https://github.com/<USER_NAME>/<REPOSITORY_NAME>.git", branch: "main"), ], targets: [ // Targets are the basic building blocks of a package, defining a module or a test suite. // Targets can depend on other targets in this package and products from dependencies. .target( name: "SwiftPluginWrapper", // リソースの場所だけでなく、依存関係として `dependencies` を追加する。追加するのはパッケージ名 dependencies: ["SwiftPlugin"]), .testTarget( name: "SwiftPluginWrapperTests", dependencies: ["SwiftPluginWrapper"]), ] )
するとこのパッケージの依存関係が定義でき、依存先のパッケージの内容を利用することができるようになります。
ラッパーパッケージ側を開くと、 Package Dependencies として SwiftPlugin
が認識されているのが分かります。
実際に、指定したパッケージを利用するようにしてコードを書いてみます。
import SwiftPlugin @_cdecl("add") public func add(_ a: Int32, _ b: Int32) -> Int32 { let utility: NativeUtility = NativeUtility() let result: Int32 = utility.add(a: a, b: b) return result }
add
関数の中で SwiftPlugin
側の NativeUtility
クラスをインスタンス化しているのが分かります。
これをビルドしてUnity側で利用してみましょう。
依存関係があるパッケージをビルドすると、依存先(GitHub上のパッケージなど)も自動的に追加されビルド対象となります。
ちなみにローカルの場合はふたつのパッケージがそれぞれビルドされました。GitHub上のものを指定したときはひとつのライブラリになったので、依存のさせた方によって挙動が異なるかもしれません。適宜、生成されたライブラリをインポートするようにしてください。
上記の設定でビルドした結果が以下です。ふたつのパッケージ(libSwiftPlugin
と libSwiftPluginWrapper
)の .dylib
ファイルができているのが確認できます。
iOS向けも同様に SwiftPlugin
と SwiftPluginWrapper
の .framework
が生成されています。
これの使い方は前述の通りです。 .dylib
および .framework
をUnityにインポートし、 DllImport
属性を付与した関数を定義すれば利用できます。
以下は実行した結果です。
ラッパーライブラリ側から、 libSwiftPlugin
内のコードも正常に呼び出せていることが確認できます。
注意点メモ
Unityはリロードが必要
Unityは、ライブラリを一度ロードするとリロードしてくれません。そのため、今回のように徐々に機能を作っていく場合、新しくビルドしたライブラリを入れ直しても処理が更新されません。反映させたい場合はUnityを再起動してください。
パッケージの依存が解決できない
GitHub上のパッケージを指定した場合、基本的にはなにもしなくても利用可能になりますが、GitHubのリポジトリ名とパッケージ名が一致していない場合、パッケージの依存関係が解決できずにエラーになってしまう現象がありました。
その場合は以下のように dependencies
を設定すれば解決できます。
// GitHub上のパッケージを指定 dependencies: [ .package(url: "https://github.com/ggerganov/llama.cpp.git", branch: "master"), ], // dependenciesには `.product` を利用して名前とパッケージ名を指定 .target( name: "llamacpp-wrapper", dependencies: [ .product( name: "llama", package: "llama.cpp") ]),
応用編
SwiftではなくC++になってしまいますが、NDIというネットワークで映像を配信できるフレームワークのC++実装を、Unityで扱えるようにした記事を以前に書きました。実際に存在しているフレームワーク・ライブラリをUnityで扱えるようにしたものなので、今回のサンプルではなく実際に利用する際の実装の仕方の参考になると思うので、興味があったらぜひ読んでみてください。
まとめ
Swiftパッケージはかなり簡単にライブラリ化することができます。とはいえ、今回のようなシンプルな構成ではないとビルド時にエラーなどが発生するかもしれません。しかし、基本となる部分をおさえておけば、問題が起きたときの切り分けも簡単になるでしょう。
最近ではApple Vision Proが発売され、iOSのみならずvisionOSも登場しました。今後、Swiftを利用してvisionOS向けのライブラリなども作ることになっていくと思います。特にApple Vision Pro開発はUnityではかゆいところに手が届かないことも多々あります。
そのときはSwift側で機能を実装して、それをUnity(C#)から呼び出す、という構成を取る必要が出てくることもあり得るでしょう。そんなときに参考にしてみてください。