Rust で Unity Native Plugin を実装する

7 min read読了の目安(約6600字

はじめに

この記事は Unity Advent Calendar 2020 の 14 日目の記事です。

昨今、注目度が上がってきているプログラミング言語 "Rust" を用いて Unity の Native Plugin を実装する方法と、私が最近開発をしている "Unity Native Plugin API for Rust" の紹介記事になります。

テストを兼ねて Unity が提供している Native Plugin のサンプルコードを Rust に移植していますのでこちらも御覧ください。

"C++ Rendering Plugin example for Unity" ままですが、ネイティブプラグイン側は Rust 実装に差し替えています。

Rust で Native Plugin を書く

この事自体は実際はさほど難しい事ではありません。 Rust は C ABI と互換性のある DLL の作成が可能ですので、そのように DLL を作成して Unity の C# から DllImport でロードするだけです。この辺りは .NET 一般と変わりません。

Rust で export する関数を定義する場合、次のように Attribute を付与します。

#[no_mangle]
#[allow(non_snake_case)]
extern "system" fn CalcAdd(a: i32, b: i32) -> i32 { a + b }
  • extern "system" はプラットフォームの API 呼び出し規約に準拠するものです
    • C# の CallingConvention.Winapi 相当
    • CallingConvention.Winapi は DllImport のデフォルト (プラットフォームによらず)
  • "#[no_mangle]" をつけることで Rust のマングリングを抑制します
  • Rust の命名規則で関数名は snake case にしないといけないので、 "#[allow(non_snake_case)]" を付けて warning が出さないようにします

C# 側は次のようにします。

[DllImport("rustlib")]
static extern int CalcAdd(int a, int b);

ちなみに Rust の命名規則を優先する場合は次のように C# の DllImport の EntryPoint で対応します。

#[no_mangle]
extern "system" fn calc_add(a: i32, b: i32) -> i32 { a + b }
[DllImport("rustlib", EntryPoint = "calc_add")]
static extern int CalcAdd(int a, int b);

Rust を使う場合での課題

Rust で Native Plugin を書く場合、 IUnityInterfaces などを始めたとした Native Plugin API を使えないという問題があります。これらが使えないと Unity の GPU リソースが使えないなど Native Plugin を実装する際に制限がついてしまうという事になります。

Rust は比較的新しいプログラミング言語ですので C/C++ などと異なり既存コードの流用というケースはあまりないと考えられます。パフォーマンス面からゲームロジックをネイティブコードで書く、という事も以前だったら考えられたとは思いますが最近の Unity は C# でのパフォーマンス改善が進んでおり、クロスプラットフォームという観点からも可能な限り C# で書くのが望ましいと考えます。

よって Native Plugin を書く、というケースはプラットフォーム固有機能のアクセスが主目的となると思いますが、その際に Unity で管理している GPU リソースのアクセスが必須になるケースはかなり多いと思います。例えばビデオデコーダー/エンコーダーは最たるものでしょう。

Unity Native Plugin API は C/C++ 用として提供されており、 Rust では使うことができません。

Unity Native Plugin API for Rust

ということで C/C++ 用の Unity Native Plugin API を Rust 用に移植したものが "Unity Native Plugin API for Rust" になります。

大ざっぱには rust-bindgen で生成した Binding ライブラリ (unity-native-plugin-sys) とそれをベースに Rust らしい API として実装したもの (unity-native-plugin) で構成されています。

基本的な使い方

Cargo.toml の dependencies に定義を追加します。

[dependencies]
unity-native-plugin = { version = "*", features = ["d3d11"] }

実際は version は特定のものを指定した方がよいでしょう。

features は使用対象の API を指定してください。 0.4.1 現在は次のものが指定できます。

  • d3d11 - IUnityGraphicsD3D11
  • d3d12 - IUnityGraphicsD3D12
  • profiler - IUnityProfiler

Vulkan については Ash という Rust の Vulkan Binding Library に依存させている関係で分離しています (unity-native-plugin-vulkan) 。 Metal は現状は対応していません。

次に lib.rs にエントリーポイント (UnityPluginLoad) の定義をします。これは用意したマクロを用います。

unity_native_plugin::unity_native_plugin_entry_point! {
    fn unity_plugin_load(interfaces: &unity_native_plugin::interface::UnityInterfaces) {
        // UnityPluginLoad が呼ばれた時の処理を書く
    }
    fn unity_plugin_unload() {
        // UnityPluginUnload が呼ばれた時の処理を書く
    }
}

UnityPluginLoad / UnityPluginUnload に対応する処理がなければ中身は空で問題ありませんが、定義自体は必ず書いてください。

準備ができれば次のような手続きで所定のインターフェースが取得できます。これは IUnityGraphicsD3D11 の Rust 版を取得し、そこから ID3D11Device を取得します。
(Win32 API の Binding として別途 winapi-rs が必要)

let intf = unity_native_plugin::interface::UnityInterfaces::get()
    .interface::<unity_native_plugin::d3d11::UnityGraphicsD3D11>().unwrap();
let device = intf.device() as *mut winapi::um::d3d11::ID3D11Device;

これで Rust からも Unity の機能を利用した Native Plugin の実装が可能になりました。

Tester Library

Unity Native Plugin を開発する際、面倒な点として "Unity Editor 上でロードすると Editor を終了するまでアンロードされない (起動中に DLL の入れ替えができない)" というものがあります。

Rust の Native Plugin API Library では Rust のテスト機能の仕組みに乗っかって cargo 上で Plugin のコードをテスト実行できる仕組みを用意しています (作業中) 。

IUnityInterfaces などの代用実装を行いました。テスト中からはターゲットとしている Graphics API などを Native Plugin API を介して取得、呼び出しが可能なのである程度までは Unity に持っていかずに実装、テストが行えるようになっています。

[dev-dependencies]
unity-native-plugin-tester = { git = "https://github.com/aosoft/unity-native-plugin-tester", branch = "v0.4.1", features = ["d3d11"] }

開発用なので dev-dependencies に依存定義をします。 crates.io に登録していないので GitHub 経由で参照してください。

#[no_mangle]
#[allow(non_snake_case)]
extern "system" fn FillTexture(unity_texture: *mut IUnknown, x: f32, y: f32, z: f32, w: f32) {
    // 略
}

#[test]
fn test() {
    let instant = std::time::Instant::now();
    unity_native_plugin_tester::d3d11::test_plugin_d3d11(
        (256, 256),
        |_window, _context| {},
        |_window, context| {
            let n = (instant.elapsed().as_millis() % 1000) as f32 / 1000.0;
            FillTexture(context.back_buffer().as_raw() as _, 0.0, 0.0, n, 1.0);
            unity_native_plugin_tester::window::LoopResult::Continue
        },
        |_, _| {},
        unity_plugin_load,
        unity_plugin_unload,
    )
    .unwrap();
}

テスト機能を実装した関数 (D3D11 では unity_native_plugin_tester::d3d11::test_plugin_d3d11) に各種コールバックを設定する事で疑似的な Unity Native Plugin の実行環境下でプラグインのコードを動かす事ができます。

CLion だとテスト関数を認識して IDE 上で直接実行できるのでとても便利です (VSCode でもテスト実行はできますが、 cargo test での全テスト実行だけっぽいです) 。 CLion はテスト関数のデバッグ実行もできますので CLion はいいですよ。

ウィンドウを表示してレンダリング結果の確認も行えるようにしています。

おわりに

Rust は C/C++ に代わる有力な選択肢としても注目が集まっている言語です。個人的には Rust を積極的に使っていきたいので Rust での対応をしてみました。

今のところ Rust でネイティブプラグインを書こうという気にはなりにくいかもしれませんが、簡単に API を導入して開発に着手できることや Unity Editor を介さずにある程度の動作確認ができるようにしている点は Rust (というか cargo) ならではのメリットかと思います。

Rust は言語そのものより rustup/cargo などの標準ツール類が強力で、その辺が標準化されていない C/C++ より圧倒的に楽で個人的にはそれだけでも Rust を使う理由になっています。言語の修得が難しいと言われますが、導入は簡単ですので未体験の方は是非一度お試しください。