WaaS - Unity/.NET向けの言語非依存なスクリプトエンジン
Unity/.NET で動く新しいスクリプトエンジン 「WaaS」 を公開しました。
WaaS は WebAssembly as a Script を略したもので、WebAssembly の活用により言語非依存なスクリプティングを可能にするというコンセプトのエンジンです。
WebAssembly は、特定の OS や CPU に依存しない実行ファイルのバイナリフォーマットです。もともとは Web ブラウザ上での実行を想定して策定されたものですが、仕様そのものには Web に依存する要素は特になく、ランタイムさえあれば様々な環境で WebAssembly の実行が可能です。C/C++、Rust、Go など様々な言語が WebAssembly へのコンパイルをサポートしているのも魅力で、言語の壁を越えたユニバーサルな実行基盤という、ある種のロマンを体現する存在でもあります。
このあたりの話はこちらの記事が詳しいです:
Why WebAssembly?
そんな WebAssembly ですが、実はゲームの組み込みスクリプト用途に向いている特徴があります。
言語非依存
ゲーム用の組み込みスクリプト言語といえば、Lua が覇権を握って久しいです。Lua は仕様がコンパクトで組み込みの技術的なハードルが低く、習熟が容易であるといわれています。ただ個人的にはもっと型が強く、IDE による支援が充実した言語を使いたいです。また、スクリプト作成の効率を最大化するなら、もっとシンプルでニーズに特化した構文をもつ言語を自作してもいいでしょう。WebAssembly という仕様さえ満たしていれば、WaaS は言語を問いません。
セキュリティ
WebAssembly には独立したメモリ空間が与えられるので、ホスト環境が許可しない限り、外部のメモリ空間や関数にアクセスすることができません。これは UGC などソースの信頼できないスクリプトを実行する用途に最適です。
あとは、スクリプトをゲームに埋め込む際には文字列じゃなくてバイナリになってくれたほうが安心とか、そういう細かいありがたさもあります。
ポータビリティ
WebAssembly がホスト環境に要求する条件は、前述の通りすべて明示的です。これはつまり OS や CPU、ランタイムなどの暗黙的な前提条件がなく、ポータビリティに優れていることを意味します。例えばゲームではサーバー・クライアント間のロジックの共有などにも使えるでしょう。
WebAssembly は実行ファイルフォーマットとして程よくミニマルなところを突きつつ、それが複数の言語にとっての共通ターゲットとして標準化できつつある、その二面を両立しているという点で優れているといえるでしょう。
WaaS の特長
AOT セーフ
WaaS は Pure C# でスクラッチ実装されているインタプリタで、WebAssembly 自体の JIT は行いません。つまり iOS など JIT が禁止されているプラットフォームでも実行時にロードしたスクリプトを実行できます。
Component Model への対応
WebAssembly の最初の仕様が正式公開されたのは結構前のことで、2017年にさかのぼります。これまでにも Unity で WebAssembly を実行する試みはいくつか行われているようでした。
しかしこれまで、組み込み用途のWebAssemblyにはひとつ大きな弱点がありました。それは FFI の貧弱さです。
WebAssembly のコア仕様(1.0)では引数や戻り値に使えるデータ型が i32
, i64
, f32
, f64
の4つしかなく、それ以外のデータを扱うには WebAssembly とホスト環境の双方でマーシャリングが必要でした。しかし、このマーシャリング手順には標準的な定義が乏しく、言語やツール間で互換性がありませんでした。そのため、せっかくコンパイルした WebAssembly モジュールはホスト環境に強く依存したものにならざるを得ませんでした。つまりは WebAssembly 自体の不足というより、標準的な ABI の不在こそが問題だったということですね。
しかし、こうした状況は Component Model という新たな仕様の登場によって変わりつつあります。Component Model では Canonical ABI によってマーシャリングの方法などが定義されており、これによって言語・ホスト環境にかかわらず、WebAssembly モジュールやホスト環境との間でスムーズに相互運用することができます。真のポータビリティが手に入ります!
Canonical ABI では次のような型のような型をやりとりすることができます:
- プリミティブ型(
bool
, 整数、浮動小数点数) - 文字列
- リスト
option<T>
result<T, E>
- タプル
-
record
(Rustでいうstruct) -
variant
(Rustでいうenum) enum
flags
- リソースハンドル(コンポーネントの外部にある何らかのリソースへの参照)
WaaS では Component Model に対応しました。非 Component Model な WebAssembly もちゃんと動かせますが、実用上は Component Model の使用がベースラインとなるのではないかなと思います。
ということで、Component Model でのワークフローを見てみましょう。
1. WITの作成
Component Model では、WIT (WebAssembly Interface Type) という IDL を使って型や関数のシグネチャを事前に定義します。この WIT をソースとして、ゲスト言語(WebAssembly にコンパイルする言語)・ホスト言語(C#)用のバインディングを生成します。
まずは Unity プロジェクトのルート下に wit/sequence.wit
を作成します。
以下は簡単な会話シーンで使うことを想定した WIT の例です。
package my-game:my-sequencer;
world sequence {
import env;
export play: func();
}
interface env {
show-message: func(speaker: string, message: string);
}
雰囲気だけ掴んでもらえば大丈夫ですが、world sequence
はこの WebAssembly コンポーネントのインポート・エクスポートする機能のセットを定義するものです。interface env
はホスト環境側で実装しコンポーネントにインポートする機能です。
2. ゲスト言語側の作業
WIT が作成できたら、WIT から各ゲスト言語用のバインディングを生成します。これには wit-bindgen
というツールを使います。
今回は Unity x Rust での手順を紹介します。WaaS では Rust のソースファイルを直接 Unity プロジェクトにインポートして Wasm 化できる機能を提供しています。(要 Rust ツールチェインと wasm32-unknown-unknown
ターゲット)
Assets
下の任意の場所に sequence_0.rs
を作成します。
use crate::my_game::my_sequencer::env::show_message;
wit_bindgen::generate!({
path: "../../../../../wit",
world: "my_game::my_sequencer/sequnce"
});
struct Sequence;
impl Guest for Sequence {
fn play() {
show_message("ぼく", "こんにちは!");
}
}
export!(Sequence);
3. インポート設定
作成した Rust ソースファイルのインポート設定を行います。
- Crate Root を有効化する
-
Dependencies に via Registry な依存関係を追加し、Name に
wit-bindgen
を指定する -
Componentize を有効化する
-
Componentization Settings で Wit Directory に
wit
を指定する -
World に
my-game::my-sequencer/sequnce
を指定する
-
Componentization Settings で Wit Directory に
このように、本来 Cargo.toml
に記述する依存関係等をインポート時に指定し、自動でWebAssemblyにコンパイルすることができます。また先ほど作成したWITを指定することで、Component Model で必要となるメタデータ等を WebAssembly に付与する処理(コンポーネント化)を行えます。これによってスクリプトの編集と結果の確認をすばやく行えるようにしています。
4. C# 用のバインディングの生成
作成したコンポーネントを簡単に実行するために、C# のバインディングを作成します。WaaS では WIT ファイルから C# のインターフェースを生成するための CLI ツール wit2waas
を提供しています。
Unity プロジェクトのルートで wit2waas
を実行します。
cargo install wit2waas
wit2waas --out "Assets/WaaS Generated"
すると、先ほどの WIT から Assets/WaaS Generated/my-game/my-sequencer
に C# コードが生成されます。
// <auto-generated />
#nullable enable
namespace MyGame.MySequencer
{
// interface env
[global::WaaS.ComponentModel.Binding.ComponentInterface(@"env")]
public partial interface IEnv
{
[global::WaaS.ComponentModel.Binding.ComponentApi(@"show-message")]
global::System.Threading.Tasks.ValueTask ShowMessage(string @speaker, string @message);
}
}
いろいろと属性がついていますが、これによって Source Generator が具体的に WebAssembly コンポーネントと値をやり取りするためのコードを生成します。
IEnv
(WIT では interface env
) は、C# から WebAssembly 側にインポートする機能のセットを定義したものでした。これに実装を与えておきます。
internal class Env : IEnv
{
public ValueTask ShowMessage(string speaker, string message)
{
Debug.Log($"{speaker}「{message}」");
return default;
}
}
5. 実行する
これで Rust で作成したコンポーネントを Unity で実行する準備が整いました。
using System.Collections.Generic;
using MyGame.MySequencer;
using UnityEngine;
using WaaS.ComponentModel.Runtime;
using WaaS.Runtime;
using WaaS.Unity;
public class RunSequenceTest : MonoBehaviour
{
[SerializeField] private ComponentAsset componentAsset;
private async void Start()
{
var component = await componentAsset.LoadComponentAsync();
var instance = component.Instantiate(new Dictionary<string, ISortedExportable>()
{
{ "my-game:my-sequencer/env", IEnv.CreateWaaSInstance(new Env()) }
});
using var context = new ExecutionContext();
var sequence = new ISequence.Wrapper(instance, context);
await sequence.Play(); // ぼく「こんにちは!」
}
}
Component Model ワークフローの全体像
これまでの手順の全体像はこんな感じです。
WIT がすべてのソースとなってホスト用のバインディングとゲスト用のバインディングを生成します。
Rust Importer
上記の手順では Rust のソースファイルを Unity に直接インポートできる Rust Importer という WaaS の機能を利用しています。これがないと Unity の外部で wit-bindgen
を走らせて、Rust を Wasm にコンパイルして、それを Unity にインポートして……という手順がスクリプトを編集するたびに発生してしまうところ、スクリプトの編集からコンポーネント化までワンストップでできるようになっています。
これは Unity の ScriptedImporter
で *.rs
ファイルを Wasm にコンパイル・コンポーネント化する実装を行っています。また同時に Cargo.toml
を自動生成していて、これによって Rust 向けの IDE 機能との互換性も得られます。つまりちゃんと Rust の補完が効くし、cargo
の依存関係を追加することも可能です。
実際に使ってみると、Rust ソースを編集して保存ボタンを押すだけでコンパイルが完了するのはなかなか体験として優れている、というか C# のそれと遜色ない快適さです。これでかなり現実的なワークフローが構築できたんじゃないかと思っています。今後は Rust 以外の言語にも同様の対応を広げていきたいです。
非同期
さて、先ほどは触れませんでしたが、IEnv
に定義されたメソッドの戻り値は ValueTask
でした。
[global::WaaS.ComponentModel.Binding.ComponentInterface(@"env")]
public partial interface IEnv
{
[global::WaaS.ComponentModel.Binding.ComponentApi(@"show-message")]
global::System.Threading.Tasks.ValueTask ShowMessage(string @speaker, string @message);
/* ... */
}
Rust 上では ShowMessage()
を同期的に呼びだすコードでしたが、C# 側は非同期にできます。非同期メソッドが動いている間 WebAssembly 側の実行は自動的にサスペンドされます。これは会話イベントなどを簡単に手続き的に書けるようにするために対応しました。
結局、どの言語で書くべき?
WaaS はいろんな言語が使える!とはいいつつ、実際のところ WebAssembly ではどんな言語が使えて、どんな言語を選ぶべきなのでしょうか?
これはまだ私も手探りですが、まずは Component Model に対応していること。この時点で割と選択肢が狭まる節はあるんですが、wit-bindgen
は現在 Rust, C, Java, Go, C#, MoonBit に対応しています。ただ、WASI が今後 Component Model を基盤に策定されていくことなどを考えると、今後 Component Model への対応言語がより拡大していくという期待感は持てます。
次にコードサイズ。これはゲームへの組み込み用途では、ビルドサイズ、ダウンロードサイズ、実行時のメモリフットプリント、ロード時間、パフォーマンスなどに影響します。C#, Go, Java あたりは、それぞれの言語のランタイム自体を WebAssembly バイナリの中に含めるのでコードサイズが膨らみがちです。このあたりは Rust, C, MoonBit が強いところだと思います。
そして肝心なのが言語としての書きやすさです。さすがにゲーム向けのスクリプトを C で書きたい人はあまりいない(要出典)と思いますので除外すると、Rust と MoonBitが残ります。
まず Rust ですが、WebAssembly 開発の言語としては最もよく使われていて(私見)、WebAssembly 関係のツールが Rust で実装されていることが多かったり、Component Model 自体のデザインにもかなり Rust の影響が見て取れます。シンタックスもそこそこミニマルで自分としては嫌いじゃないんですが、ネックとなるのはやはり習得の難しさ、特に所有権周りの理解でしょう。とはいえ、いち関数のスコープで完結するような簡単なスクリプトであれば意外と複雑な所有権の理解は要求されないんじゃないかという感じもします。
fn play() {
show_message("シグモ", "よければ……バッテリーを持ってきてくれないですか");
match show_options(&["いいよ".to_string(), "だめ".to_string()]) {
0 => show_message("シグモ", "ありがとう……"),
1 => show_message("シグモ", "えっ……"),
_ => {}
}
show_message("シグモ", "……");
}
どうでしょうか。&["いいよ".to_string(), "だめ".to_string()]
のあたりはちょっとグロい?
うーん、そうですね……。
この点、MoonBit は Rust の難しさを克服できるかもしれません。MoonBit はまだ正式にはリリースされていない新しいプログラミング言語ですが、次のような特徴があります。
- 公開当初から WebAssembly をメインターゲットとしている
- WebAssembly のコードサイズが(Rust より)小さい傾向
- Rust に似たシンタックス
- GC があり、所有権はない
先ほどのコードを MoonBit で書きなおすとこんな感じになります。
@env.show_message("シグモ", "よければ……バッテリーを持ってきてくれないですか")
match @env.show_options(["いいよ", "だめ"]) {
0 => @env.show_message("シグモ", "ありがとう……")
1 => @env.show_message("シグモ", "えっ……")
_ => ignore()
}
@env.show_message("シグモ", "……")
これだけすっきり書ければ十分なんじゃないでしょうか。MoonBit は VSCode 向けの拡張も公式から出ていて、コード補間を効かせながらスルスルと書けてなかなかいい感じです。コードサイズが小さいのもゲームへの組み込み用途としては嬉しいですね。
最大の弱みはまだベータであることで、頻繁に仕様変更があったりするのがつらいですが、今後成熟すれば WaaS で使える言語としては最もちょうどいいものになるんじゃないかと思っています。
自作言語の可能性
自分は特に言語を作ったことはないですが、LLVM な言語であれば Emscripten によって比較的簡単に WebAssembly への出力に対応できるはずです。Component Model については、wit-bindgen
にあたる部分を自作する必要があるのでそこはちょっと大変かもしれません。
課題
各言語向けの Importer の拡充
この記事で紹介した Rust Importer ですが、言語非依存なランタイムを標榜するからにはどの言語でも最大限効率的なワークフローを構築できるようにしたいところです。
Component Model がまだ流行ってない
Component Model は phase 1 であり、まだ広く採用されている状態ではありません。Component Model は WebAssembly のバイナリ仕様から拡張しているため後方互換性がなく、メジャーなランタイムではまだ wasmtime くらいしか Component Model に対応していません。
私は WebAssembly がここまで受け入れられた要因の一つは、コア仕様のミニマルさにあると思っています。Component Model の Explainer を見ていただくとわかるのですが、その仕様はかなり巨大で、これは Component Model 普及の妨げになるかもしれません。
それでも Component Model に対応したのは、現状 Cacnonical ABI にあたる部分を欲するとそれ以外に実用的な選択肢がなく、また wit-bindgen
や cargo-component
などのツールが既に充実していて、巨人たちが Component Model を流行らそうとするパワーの大きさを感じるからです。うーん、流行ってほしい……。
規格を流行らせるには、よい実装の存在が重要だと思います。WaaS が Component Model を「ちゃんと使える道具」として活用していくことで、そこに少しでも寄与できたら嬉しいですね……。
おわり
この記事では WaaS を利用する目線で紹介を行いましたが、WaaS 自体の開発や設計については別記事で紹介しています。こちらもぜひどうぞ→
Discussion