Unity WebGL から Rust (WebAssembly) を呼び出す
概要
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
に以下の内容を追記します。
[lib]
name = "unity_wasm_example"
crate-type = ["cdylib"]
依存関係を追加します。
cargo add wasm-bindgen
cargo add noise
ライブラリ本体を記述します。
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をインストールします。
以下のコマンドでwasmコンパイルします。unity_wasm_example.js
とunity_wasm_example_bg.wasm
が得られます。
wasm-pack build -t no-modules
wasm-pack
はデフォルトではwebpackを前提とした形式でコードを出力しますが、-t no-modules
をつけると生のJSから直接ロードできる形式になります。今回はこちらのほうがいいでしょう。
まずはエディタ上で動かす
WebAssemblyに行く前にエディタ上での動作を確認します。
エディタで使う用のDLL (Windowsの場合) をコンパイルします。
cargo build
Windows用ネイティブプラグインとしてUnityにインポートします。ついでにネイティブプラグインを呼び出すC#コードも書いておきます。
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();
}
}
うまく動いてます。
.jspre
を書く
WebAssemblyをロードするエディタでの動作が確認できたところで、いよいよWebAssemblyに行きます。
まず、さきほど生成したuntiy_wasm_example_bg.wasm
をStreamingAssetsの中にコピーします。
次にunity_wasm_example.js
をunity_wasm_example.jspre
としてUnityにインポートし、末尾にちょっとした処理を書き加えます。
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のロードを行います。
.jslib
を書く
WebAssembly/C#の橋渡しをするC#に書いたDllImport
関数は本来ネイティブプラグイン呼び出しに使うものですが、WebGLにおいては.jslib
に記述したJavaScriptを呼び出します。いろいろと呼び出しのルールがあるのでドキュメントを見ておくのがおすすめです。
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_alloc
とwasm_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の初期化処理を行うように改造すればよさそうです。
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の(メモリ空間を共有した)動的リンク/静的リンクとかってできないのかな。
参考
Discussion