56

この記事は最終更新日から3年以上が経過しています。

投稿日

更新日

Rustで実装したアルゴリズムをUnityから使う

Unity (ゲームエンジン) から Rust で書いたネイティブバイナリを実行する、という行為を試してみました。
サンプルコードは こちら

なぜRust ?

以前、Unity上で、C#でプログラマブルに任意の形状のメッシュを生成するといったことをやってみました。

demo_small.gif

( たとえばこういうふうに、実行中に入力から任意の形状を生成して、メッシュにして描画できると楽しい )

しかし、三角形分割などの幾何アルゴリズムをC#で素朴に実装してみたところ、本に載っているような計算量少なめのアルゴリズムに則ったとしても、(むしろ則ったせいで)、GCゴミの発生を抑えることの難易度がかなり高いな、という感想を持ちました。

こういう類のアルゴリズムは、計算途中の辺や点、面の情報を参照として持ち、それらの空間的なつながりの情報を可変長のコレクションに入れて管理するといったことがどうしても頻出するのではないかと思います。
ある種の向きや順番を表現できて、重複や循環も表現できるデータ構造もよく使われているようです。たとえば二重連結辺リスト (DCEL - Double Connected Edge List) と呼ばれるようなものがあります。

結果として、アルゴリズムを実行するある過程では必要なんだけど、終わったら必要なくなるような、外からは見えない一時的なヒープアロケーションを避けるのが難しい、あるいは避けるのが面倒なことになりがちでした。

C#では、GC管理対象のメモリ確保をぽんぽん毎フレームなどの高い頻度で実行しまくると、パフォーマンス劣化につながることは間違いないため、どうにか避けたいところ。

もちろん、オブジェクトをプーリングして使いまわすとか、一度確保したバッファは使いまわすとか、テクニックは色々考えられると思いますが、まず正しく動かすことも苦労する実装をしている局面だと、そいういう気遣いを見せるのはなかなか大変です。漏れやミスも出てきそうです。

そこで、実験というか半分遊びですが、メッシュ生成みたいなものは、Rust で実装してしまえば良いんちゃうか ? ということを思いつきました。

これは、Unity の UI のソースを読んでいたところ、メッシュ生成やレイアウト計算などの肝心な部分がほとんどネイティブコードに埋められてブラックボックスになっているのを見かけて得たアイデアです。

Unity の低レイヤは c++ で実装されているそうですが、現在の僕たちにはRustという選択肢もあります。

  • Rust の実行速度は c++ と同等
  • 主要なOSやスマホ向けのクロスコンパイルも標準ツールでサポート
  • プラットフォーム固有の微妙なAPI載違いをほとんど意識することがない
  • モダンなパッケージマネージャも搭載
  • GC がない
    • ヒープアロケーションそのものはもちろん安くはないはずですが、何も考えずに実装した場合、Rustにするだけで、GCによってたまに全スレッドが遅くなるといった類の問題は緩和しそうという期待がある (未検証)

そこで、実験的に、Rust でメモリの取得/解放を含めてアルゴリズムを実装して、 Unity から呼び出す、という行為を試してみました。

今回のサンプルは、とりあえず試しに、キャットムル-ロム曲線の計算をRustで実装してUnityから呼び出す、というだけの内容です。

5cfe24463e793aff231ec40868fa22c5.gif

↑UnityからRustで実装したライブラリを呼び出し、Gizmoで可視化しているようすです。
白い点=制御点を与えると、青い線=それらを通過する曲線 の座標を計算する、という部分がRustで動いています。

中身はこれだけけですがコードは これ です。

Rust と C# のバインディング

Rust 側

Cargo.toml

Cargo.toml に以下の設定を足すことで、ビルドする対象をネイティブの動的リンクライブラリに変更します。

Cargo.toml
crate-type = ["dylib"]

外の世界から呼ぶための関数

  • Rust の関数に pub extern をつけてあげて、外からリンクできるインターフェイスの関数をつくります。
  • Rustを知らない別言語から参照できるように、#[no_mangle] 属性をつけます
// 例
#[no_mangle]
pub extern fn hoge() -> i32 {
    100
}

この状態でビルドしてみると、target/ 以下に lib<crate名>.dylib というバイナリができあがり、 hoge というシンボルが出力されていることが確認できました。(macOSの場合)

⟩ nm -gU target/debug/libunigeo.dylib | grep hoge
00000000000083c0 T hoge

Rustの外へ構造体を渡す/受け取る

Rustで定義した構造体を外とやりとりする場合は、 structの定義に #[repr(C)] 属性をつけておきます。
こうすることで、メモリ上のレイアウトが C で書いた場合と同様になってくれるそうです。便利です。( c言語自体がもはや共通のバイナリのインターフェイスかのようなノリですね)

#[repr(C)]
pub struct A {
    a: f32,
    b: f32,
}

Rustの外の別の言語側でも、全く同じメモリレイアウトの構造体の定義さえあれば、Rust側で関数の引数として受け止ったり、返したりすることができます。

// 例。 上で定義したAという構造体をつかう
#[no_mangle]
pub extern fn a_new(v: A) -> A {
    A { a: v.a * 100.0, b: v.b * 200.0 }
}

Rust側で確保した変数のポインタを外へ返す

今回は、ヒープアロケーションをRust側へ持っていくというモチベーションがあったため、Rust側でヒープに確保した変数のポインタを外側へ渡したり、受けとったりするということもやってみました。

外側にポインタを返す場合、まず、寿命を任意の長さにするため、 Box::new でボクシングしてあげます。

次に、確保した Box に対して、Box::into_raw を呼び出すことによって、生ポインタをつくってあげます。
ここで、into_raw によってBoxは消費され、このとき、できあがった生ポインタはRustのメモリ管理対象から外れています。 Box::into_raw したポインタに対しては、手動でメモリを解放する責任が生じることに、注意です。

#[no_mangle]
pub extern fn catmull_rom_spline_new(closed: bool) -> *mut CatMullRomSpline {
    let spline = CatMullRomSpline::new(closed);
    let spline = Box::new(spline);

    Box::into_raw(spline)
}

というわけで、ポインタの破棄を外から叩けるように、用意しておきましょう。、

Box::from_raw をすると、生ポインタをもう一度 Rustの管理対象のBox に変換します。
これをdropすれば後片付けは完了です。
( とくに明示的になにもしなくても、moveせずにスコープを抜けるとdropします。

#[no_mangle]
pub extern fn catmull_rom_spline_drop(spline: *mut CatMullRomSpline) {
    unsafe { Box::from_raw(spline) };
}

外からポインタを受けとり、それに対してメソッドを呼び出す例は以下のようなかんじになります。

#[no_mangle]
pub extern fn catmull_rom_spline_add_control_point(spline: *const CatMullRomSpline, p: Vec3) {
    let spline = unsafe { &*spline };
    spline.add_control_point(p);
}

これでだいたい外側からRustのコードをつかうことができそうです。

インターフェイスの定義が面倒なので、マクロとかにするのが良いかも

Unity 側

ネイティブプラグイン

Unity には、 ネイティブバイナリを動的リンクして呼び出す仕組みが搭載されているので、それをつかいます。

Unity - Manual: Native plug-ins

  • プロジェクトの任意の場所に Plugins/ ディレクトリをつくるとその下は特別扱いされ、ネイティブバイナリをロードしてくれます
  • プラットフォームごと、たとえば macOSであれば、 Plugins/macOS ディレクトリをつくり、Rustでビルドしたバイナリを置きます。

DllImport

c# 側では、DllImport 属性で c# としてのインターフェイス定義を書いてあげることで、ネイティブバイナリの中の関数を呼びだすことができます。

[DllImport("libunigeo")]
internal static extern IntPtr catmull_rom_spline_new(bool closed);

sturctのレイアウト

[repr(C)] な構造体と同じメモリレイアウトの型を C# 側で定義するためには、C#の [StructLayout] 属性を以下のようにするといけるようです。

    [StructLayout(LayoutKind.Sequential)]
    public struct A
    {
        float a;
        float b;
    }

ちなみに、Unity のVector3 も、Rust の #[repr(C)] をつけた float 3つの構造体と一致させることができました。Unityのこの辺の型は実装がc#ではなくc++側にあるらしいですが、必ず #[repr(C)] 相当になると考えて良いのかよくわかってません :thinking:

Disposable Pattern

C#側では、IDisposableを実装して、非マネージドなリソースの解放が必要であることを明示するのが驚きがないとおもいます。さらに、メモリ解放を忘れないように Disposable パターン で 明示的な解放がない場合もファイナライザによって Dispose が呼ばれるようにしておくと安心です。

Dispose メソッドの実装 | Microsoft Docs

    public class CatmullRomSplineRust : IDisposable
    {
        // ...

        readonly IntPtr ptr;
        bool disposed;

        public CatmullRomSplineRust(bool closed)
        {
            ptr = Bindings.catmull_rom_spline_new(closed);
        }                   

        public void Dispose()
        { 
            Dispose(true);
            GC.SuppressFinalize(this);           
        }

        protected virtual void Dispose(bool disposing)
        {
            if (disposed)
                return; 

            Bindings.catmull_rom_spline_drop(ptr);
            disposed = true;
        }
    }

実行時のエラー

Unity では、ネイティブプラグイン側がエラーになると、プロセスがクラッシュするというド派手な挙動をするようになっていて、どきどきしてしまいますが、ログファイルをみにいくと Rust のエラーをちゃんと読むことができます

 thread '<unnamed>' panicked at 'attempt to subtract with overflow', src/spline.rs:60:58
 note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace.
 fatal runtime error: failed to initiate panic, error 5
 [Unity Package Manager (Upm)]
 Parent process [29918] was terminated

ネイティブプラグインのリロード

今回はじめて知ったのですがUnity では、エディタ再起動しないと、ネイティブプラグインが再読み込みされないという仕様(?)になっているようです。
Rust側でビルドしなしているのに変更が反映されない、という現象にずいぶん嵌ってしまいました ><

感想

だいたいこんなかんじです。

やはり、多言語をまたいでしまうと、お互いにインターフェイスを定義するところが若干面倒です。
( 機械的に生成するとかのアプローチは可能だと思いますが )

あとは、Rustだけだとアルゴリズムのビジュアライズができないので、とりあえず Unity で動いているようすを可視化してみよう、といったワークフローになったんですが、ネイティブプラグインの再読み込みのために、Unityの再起動が必要だったりする残念な仕様のせいでこのままだと効率が悪そう……。

ただ、やはりRustのcargoなどのツールは優れていて、ビルドまわりは楽なので、ネイティブプラグインの敷居がかなり下がったように感じました。用途によってはかなり可能性を感じます。

(思いつき)
たとえば、シリアライザの実装をRustに持っていってしまうとかも、もしかしたら良いかもしれません。
Rust には、実行時のリフレクション等を必要とせず、マクロによる事前のコード展開によって自動的に実装できる便利なシリアライザ (serde) があります。
一方、C#でのシリアライザの実装は、実行時に初回だけリフレクションをつかい、ILコードを生成するといったスマートなテクニックが使われていますが、Unityではプラットフォームによってはリフレクションが動かなかったりするせいで、事前にC#コードを生成するといった借地がとられることがあり、ちょっとそういうのが煩雑ですし、パフォーマンスもRustのほうに優位さがありそうです(未検証)

そんなかんじです。気が向いたら引き続きRustとUnityで遊んでみようとおもいます。

新規登録して、もっと便利にQiitaを使ってみよう

  1. あなたにマッチした記事をお届けします
  2. 便利な情報をあとで効率的に読み返せます
ログインすると使える機能について

コメント

この記事にコメントはありません。
あなたもコメントしてみませんか :)
新規登録
すでにアカウントを持っている方はログイン
56