🏭

Unity WebGL から Rust (WebAssembly) を呼び出す

2022/07/26に公開

概要

Unity WebGLからRust (WebAssembly) の処理を呼び出すやつをやります。

WebAssemblyはC/C++/Rustなどからコンパイルすることが可能であるため、それらの言語で書かれたネイティブプラグインをWebGLでも使う、といったことが可能なのがうれしいポイントです。

ただしWebGL環境でのネイティブプラグインはいろいろと特殊なので、気を付けるポイントがいくつかあります。

手順

だいたいの手順は以下の通りです。

  • WebAssemblyをコンパイルする
  • C#側でexternメソッドを書く
  • WebAssemblyをロードするJavaScript (.jspre) を書く
  • WebAssembly/C# 間の橋渡しをするJavaScript (.jslib) を書く

RustをWebAssemblyにコンパイルする

せっかくなのでクロスプラットフォーム(Windows, macOS, iOS, Androidなど)に対応することを前提として書いてみます。WebGL以外の各環境ではUnityから直接ネイティブプラグインとしてロードできる形式でコンパイルするため、Rust側はC Interopできる形式でAPIを記述し、C#側はDllImportを使ういつものパターンです。

対してUnityのWebGLビルドではWebAssemblyを直接ネイティブプラグインとして使用する方法がありません。Rust <-> JS <-> C#といった具合に間にJSが挟まる感じになります。今回はRustとC#は単一のコードベースでクロスプラットフォーム対応できるようにして、WebGL特有の事情はJavaScriptの層で吸収する方針でいきます。

cargo new --lib unity-wasm-example

Cargo.tomlに以下の内容を追記します。

Cargo.toml
[lib]
name = "unity_wasm_example"
crate-type = ["cdylib"]

依存関係を追加します。

cargo add wasm-bindgen
cargo add noise

ライブラリ本体を記述します。

lib.rs
use std::{
    alloc::{alloc, dealloc, Layout},
    mem::size_of,
};

use noise::*;

use wasm_bindgen::prelude::*;

//加算
#[no_mangle]
#[cfg_attr(target_family = "wasm", wasm_bindgen)]
pub extern "C" fn add(lhs: i32, rhs: i32) -> i32 {
    lhs + rhs
}

//加算(配列)
#[no_mangle]
#[cfg_attr(target_family = "wasm", wasm_bindgen)]
pub extern "C" fn add_range(buffer: *const i32, length: usize) -> i32 {
    let slice = unsafe { std::slice::from_raw_parts(buffer, length) };

    let mut sum: i32 = 0;

    for n in slice {
        sum += n
    }

    sum
}

//ノイズ
#[no_mangle]
#[cfg_attr(target_family = "wasm", wasm_bindgen)]
pub extern "C" fn fill_noise(buffer: *mut f64, length: usize) {
    let perlin = Perlin::new();
    let slice = unsafe { std::slice::from_raw_parts_mut(buffer, length) };

    for i in 0..slice.len() {
        slice[i] = perlin.get([i as f64 * 0.13, 0.0]);
    }
}

//構造体
#[no_mangle]
#[cfg_attr(target_family = "wasm", wasm_bindgen)]
pub extern "C" fn test_struct(something: Something) -> i32 {
    something._i32
}

#[repr(C)]
#[derive(Default)]
#[wasm_bindgen]
pub struct Something {
    pub _i8: i8,
    pub _i16: i16,
    pub _i32: i32,
    pub _i64: i64,
    pub _u8: u8,
    pub _u16: u16,
    pub _u32: u32,
    pub _u64: u64,
    pub _f32: f32,
    pub _f64: f64,
    pub _bool: bool,
    pub _isize: isize,
    pub _usize: usize,
}

#[wasm_bindgen]
impl Something {
    #[wasm_bindgen(constructor)]
    pub fn new() -> Something {
        Something {
            ..Default::default()
        }
    }

    #[wasm_bindgen]
    pub fn get_size() -> usize {
        size_of::<Something>()
    }

    #[wasm_bindgen]
    pub fn get_actual_ptr(&self) -> *const Something {
        self as *const Something
    }
}

//JSからAPIを呼び出すときに使う
#[wasm_bindgen]
pub extern "C" fn wasm_alloc(size: usize) -> *mut u8 {
    let layout = Layout::array::<u8>(size).unwrap();
    unsafe { alloc(layout) }
}

#[wasm_bindgen]
pub extern "C" fn wasm_dealloc(ptr: *mut u8, size: usize) {
    let layout = Layout::array::<u8>(size).unwrap();
    unsafe { dealloc(ptr, layout) }
}

単純に数値を入力と出力にもつ関数(add())、ポインタを介して配列を入力や出力として利用する関数(add_range(), fill_noise())、引数に構造体をもつ関数(test_struct())を用意してみました。下の方にwasm_alloc()wasm_dealloc()という関数がいますが、これは後で使います。

今回はwasm-bindgenならびにwasm-packを使ってWebAssemblyコンパイルします。これらはWebAssemblyにコンパイルされたRustコードとJavaScriptが相互運用するのに便利なグルーコードを自動生成してくれます。今回はtest_struct()で構造体をうまくハンドルするのに必要です。

本来はJSから呼び出すすべての関数に#[wasm_bindgen]属性をつけるんですが、これをやるとWebAssembly以外のターゲットで当該関数がエクスポートされなくなるみたいなので、cfg_attr属性でwasmの時だけ#[wasm_bindgen]がはたらくようにします。

wasm-packをインストールします。
https://rustwasm.github.io/

以下のコマンドでwasmコンパイルします。unity_wasm_example.jsunity_wasm_example_bg.wasmが得られます。

wasm-pack build -t no-modules

wasm-packはデフォルトではwebpackを前提とした形式でコードを出力しますが、-t no-modulesをつけると生のJSから直接ロードできる形式になります。今回はこちらのほうがいいでしょう。

https://zenn.dev/dozo/articles/2d32dd640de38b

まずはエディタ上で動かす

WebAssemblyに行く前にエディタ上での動作を確認します。

エディタで使う用のDLL (Windowsの場合) をコンパイルします。

cargo build

Windows用ネイティブプラグインとしてUnityにインポートします。ついでにネイティブプラグインを呼び出すC#コードも書いておきます。

UnityWasmExample.cs
using System;
using System.Runtime.InteropServices;
using System.Text;
using UnityEngine;

public class UnityWasmExample : MonoBehaviour
{
#if !UNITY_EDITOR && (UNITY_WEBGL || UNITY_IOS)
    private const string k_dllName = "__Internal";
#else
    private const string k_dllName = "unity_wasm_example";
#endif

    [DllImport(k_dllName)]
    private static extern int add(int lhs, int rhs);

    [DllImport(k_dllName)]
    private static extern int add_range(IntPtr buffer, int length);

    [DllImport(k_dllName)]
    private static extern void fill_noise(IntPtr buffer, int length);

    [DllImport(k_dllName)]
    private static extern int test_struct(Something something);

    [StructLayout(LayoutKind.Sequential)]
    private struct Something
    {
        public sbyte _i8;
        public short _i16;
        public int _i32;
        public long _i64;
        public byte _u8;
        public ushort _u16;
        public uint _u32;
        public ulong _u64;
        public float _f32;
        public double _f64;
        public bool _bool;
        public IntPtr _isize;
        public IntPtr _usize;
    }

    private void TestLibrary()
    {
        Debug.Log($"add(1, 3): {add(1, 3)}");

        int[] addRangeBuffer = new int[32];
        for (int i = 0; i < addRangeBuffer.Length; i++) addRangeBuffer[i] = i + 1;
        
        var addRangeBufferPtr = Marshal.AllocHGlobal(addRangeBuffer.Length * Marshal.SizeOf<int>());
        try
        {
            Marshal.Copy(addRangeBuffer, 0, addRangeBufferPtr, addRangeBuffer.Length);
            Debug.Log($"add_range(): {add_range(addRangeBufferPtr, addRangeBuffer.Length)}");
        }
        finally
        {
            Marshal.FreeHGlobal(addRangeBufferPtr);
        }

        double[] fillNoiseBuffer = new double[32];
        var fillNoiseBufferPtr = Marshal.AllocHGlobal(fillNoiseBuffer.Length * Marshal.SizeOf<double>());
        try
        {
            fill_noise(fillNoiseBufferPtr, fillNoiseBuffer.Length);
            Marshal.Copy(fillNoiseBufferPtr, fillNoiseBuffer, 0, fillNoiseBuffer.Length);

            var sb = new StringBuilder();
            foreach (var n in fillNoiseBuffer) sb.AppendLine(n.ToString());
            Debug.Log($"fill_noise(): \n{sb}");
        }
        finally
        {
            Marshal.FreeHGlobal(fillNoiseBufferPtr);
        }

        var something = new Something()
        {
            _i32 = UnityEngine.Random.Range(0, 100)
        };

        Debug.Log($"test_struct(): {test_struct(something)}");
    }

    private void Start()
    {
        TestLibrary();
    }
}

うまく動いてます。

WebAssemblyをロードする.jspreを書く

エディタでの動作が確認できたところで、いよいよWebAssemblyに行きます。
まず、さきほど生成したuntiy_wasm_example_bg.wasmをStreamingAssetsの中にコピーします。

次にunity_wasm_example.jsunity_wasm_example.jspreとしてUnityにインポートし、末尾にちょっとした処理を書き加えます。

unity_wasm_example.jspre
let wasm_bindgen;
(function() {

    /* 中略 */

    wasm_bindgen = Object.assign(init, __exports);

})();

//追記:WebAssemblyのロード
wasm_bindgen(Module.streamingAssetsUrl + "/unity_wasm_example_bg.wasm").then(result => {
   //jslibからwasmメモリにアクセスできるようにする
    wasm_bindgen.memory = result.memory;
    Module["unity_wasm_example"] = wasm_bindgen;
});

.jspreはUnity WebGLのロード処理の直前に実行されるスクリプトを記述できる機能です。ここでWebAssemblyのロードを行います。

WebAssembly/C#の橋渡しをする.jslibを書く

C#に書いたDllImport関数は本来ネイティブプラグイン呼び出しに使うものですが、WebGLにおいては.jslibに記述したJavaScriptを呼び出します。いろいろと呼び出しのルールがあるのでドキュメントを見ておくのがおすすめです。

https://docs.unity3d.com/ja/current/Manual/webgl-interactingwithbrowserscripting.html

unity_wasm_example.jslib
mergeInto(LibraryManager.library, {

    add: function (lhs, rhs) {
        return Module["unity_wasm_example"].add(lhs, rhs);
    },

    add_range: function (buffer, length) {
        var module = Module["unity_wasm_example"];

        var sizeOfElem = 4; // i32

        //alloc buffer in wasm memory space
        var wasmPtr = module.wasm_alloc(length * sizeOfElem);
        var wasmBuffer = new Uint8Array(module.memory.buffer, wasmPtr, length * sizeOfElem);
        var unityBuffer = new Uint8Array(HEAPU8.buffer, buffer, length * sizeOfElem);
        
        //copy buffer from Unity to wasm
        wasmBuffer.set(unityBuffer);

        var result = module.add_range(wasmPtr, length);

        //dealloc buffer
        module.wasm_dealloc(wasmPtr, length * sizeOfElem);

        return result;
    },

    fill_noise: function (buffer, length) {
        var module = Module["unity_wasm_example"];

        var sizeOfElem = 8; // f64

        var wasmPtr = module.wasm_alloc(length * sizeOfElem);
        var wasmBuffer = new Uint8Array(module.memory.buffer, wasmPtr, length * sizeOfElem);
        var unityBuffer = new Uint8Array(HEAPU8.buffer, buffer, length * sizeOfElem);

        module.fill_noise(wasmPtr, length);

        unityBuffer.set(wasmBuffer);

        module.wasm_dealloc(wasmPtr, length * sizeOfElem);
    },

    test_struct: function (something) {
        var module = Module["unity_wasm_example"];

        var wasmObj = new module.Something();
        var wasmPtr = wasmObj.get_actual_ptr();

        var size = module.Something.get_size();

        var wasmBuffer = new Uint8Array(module.memory.buffer, wasmPtr, size);
        var unityBuffer = new Uint8Array(HEAPU8.buffer, something, size);

        wasmBuffer.set(unityBuffer);
        
        return module.test_struct(wasmObj);
    },

});

メモリ空間

UnityのWebGLビルドはWebAssemblyにコンパイルされて動いてはいますが、我々が独自にロードしたWebAssemblyとメモリ空間を共有していません。ポインタを渡して結果をやりとりするタイプのAPIでは、それぞれのメモリ空間内のデータを適切にコピーしてやる必要があります。

unity_wasm_example側のメモリ空間はModule["unity_wasm_example"].memory.bufferで取得できるよう.jspreに記述したので、それを使えばOKです。Unity側のメモリ空間はHEAPU8.bufferで取得できます。どちらもArrayBufferとなっており、ポインタをインデックスとして使えばそのままデータを参照できます。

呼び出しの際、unity_wasm_example側に一時バッファを作成する必要があります。Rust側にwasm_allocwasm_deallocという、メモリの確保と解放を行うだけの関数を書いていましたが、ここで使います。

構造体引数

C#から構造体を引数として.jslibに渡してみるとポインタが渡されます。unity_wasm_example.jsを見てみるとclass Somethingが内部的にptrというメンバを持っているので、そこをめがけてUnity側メモリからコピーしてやればいいのかと思いきや、これは罠です。Something.ptrはRust側ではstruct Something本体ではなくそれをラップしたWasmRefCell<T>というwasm-bindgen由来の構造体を指しています。実体は数バイト後ろにあるので、Rust側に実装しておいたSomething.get_actual_ptr()を使って実際のアドレスを取得する必要があります。

また、wasm側にSomethingのインスタンスを確保するためにSomething::new()を、Unityからコピーするメモリ領域のサイズを知るためにSomething::get_size()を使います。いろいろRust側で準備しないといけないのは骨が折れますね……。

動かす

やっと準備が整いました。ビルドして実行してみます。

動いているようです。

補遺

カバーできないパターン

■ 構造体の戻り値

戻り値として構造体を返すパターンに関しては、そもそも.jslibでは数値と文字列以外を戻り値にはできません。こういう場合はC#からポインタを渡して.jslib側で書き込んでもらうパターンに変えるのがよさそうです。

WebAssemblyのロードを待つ

.jspre内に書いたWebAssemblyのロード処理は非同期ですが、jspre内ではawaitできないため、ロード処理投げっぱなしでUnityの初期化が行われてしまっています。WebAssemblyのロードが終わる前に.jslibの呼び出しが行われるとヤバいです。

■ 対策:Unity側でWebAssemblyのロードを待つ

一番手軽な対策は、Unityで最初に読み込まれるシーンを新たに作成し、その中でWebAssemblyのロードを待つ方法かと思います。ちょっと嫌だけど……。

■ 対策:WebGLテンプレートを改造する

WebGLテンプレートをいじっていいならば好き勝手できます。WebAssemblyのロードを待ってからUnityの初期化処理を行うように改造すればよさそうです。
https://docs.unity3d.com/ja/current/Manual/webgl-templates.html

StreamingAssetsが使えない場合

unityroomなどStreamingAssetsが使えない環境の場合、.wasmをどこに置いてロードするかが問題になってきます。

■ 対策:独自サーバーに置く

unity_wasm_example.jsではwasmをfetchを使ってロードしているので、独自サーバーに置いてURLを叩くことが可能です。CORSにだけ注意。

■ 対策:.jspreに埋め込む

力技ですが、.wasmをbase64エンコードして*.jspre内に埋め込み、data:スキームを使ってロードすることができます。これならなんでもアリです。

感想

「Rust->WebAssemblyすればUnityから呼べるやん!」という発想をし、意外とやってる人が少なさそうだったので手を出してみましたが、思った以上にめんどくさい……。特にjslibを書く作業がかったるいので、自動化できないかな~とか考えています。C#側にAttributeつけて上手くアノテーションすればワンチャン……?

Unityが公式にWebAssemblyネイティブプラグインに対応してくれたら嬉しいんですが、メモリ空間を共有してないことからポインタの扱いが定まらないため、実現は難しいかもしれません。WebAssemblyの(メモリ空間を共有した)動的リンク/静的リンクとかってできないのかな。

参考

https://learning.unity3d.jp/8224/
https://zenn.dev/dozo/articles/2d32dd640de38b

Discussion