WASM Component Modelでゲームをスクリプティングしてみる
これの話をします
試作リポジトリ: https://github.com/Pctg-x8/peridot-wasm-scripting-test
WASM Component Model
詳しい話は他の記事もあるのでここでは省略しますが、WASM(WebAssembly)に他システム/コンポーネント間とのデータのやり取りの方法などを定めた拡張仕様です。
基本的なデータ型(文字列型などメモリの確保が必要なデータ型を含む)に加えて構造体などの複合データ型のやり取りを、相手側がどういう言語を使っているかを意識することなく統一的に行えるようになります。
この特徴によりWASM Component Modelに準拠したWASMバイナリさえ用意できれば作り方に制限はないので、柔軟なプラグインシステムやゲームエンジンのスクリプティング環境で採用するのにピッタリです。というわけで、今回はWASM Component Modelを使ってゲームエンジンのスクリプティング環境もどきを試しに作ってみたというお話になります。
Wasm Interface Type
Wasm Interface Type(WIT)とは、コンポーネントがどのようなメソッドを提供するか、どのようなグローバル関数を必要とするか、またそこで必要となるデータ型はどういったものか、といった各種インターフェイスを定義するファイルです。このファイルはHuman-readableなテキストファイルで、例えば今回の場合はscript/wit/world.wit
を用意しています。
いちばん重要な部分は一番下にあるworld
の定義で、ここでimport/exportする関数/インターフェイスを宣言します。インターフェイスを宣言した場合は以下のような挙動になります。
-
import <interface>
: そのインターフェイスを実装したインスタンスがグローバル環境に存在することを期待します -
export <interface>
: そのインターフェイスをコンポーネントが実装していることを期待します
WASM Component Modelでは単純なrecord
(struct) variant
enum
flags
といった型のほか、resource
と呼ばれる型が存在します。
これはいわゆるオブジェクト型で、コンストラクタや各種メソッドなどを持ちます。resourceのインスタンスは実装上は「ハンドル」というポインタのようなものでやり取りされます。
今回のスクリプティングインターフェイス
続きの話をする前に、今回のスクリプトの仕様を書いておきます。
今回作ったものはゲームを動かすためのスクリプトになります。ただしUnityなどとは違って特定のオブジェクトに紐づくスクリプトではなく、ゲーム全体を動かすスクリプトとします(Unreal EngineのGameInstance Subsystemが近い立ち位置になるかと思います)。
今回のスクリプトコンポーネントがexportすべき関数はscript/wit/world.wit
に書いてあるとおり、以下の2つです。
-
entrypoint: func()
: スクリプトのエントリポイントであり、スクリプトコンポーネントがロードされて実体化された直後に一度だけ呼ばれます -
on-message: func(id: u32)
: スクリプトインスタンスに対してエンジン側からメッセージが送られたときに呼ばれます
このように、簡素なメッセージベースのシステム構成となっています。
ゲームのupdateタイミングはengine
インターフェイスにあるsubscribe-update: func(id: u32)
を呼ぶことでon-message
を経由して受け取れる形となります。subscribe-update
で指定したid
がon-message
の引数となります。
RustでWASM Component ModelのWASMバイナリを作る
本来言語は何でも良いのですが、今回はスクリプト側がRustなのでRustにおける作り方を軽く書いておきます。詳しくは https://component-model.bytecodealliance.org/language-support/rust.html を見るのが良いです。
RustでこのタイプのWASMバイナリを作る場合、まず必要となるのはcargo-component
です。なのでまずはこれをcargo install
します。
$ cargo install cargo-component
そしたら普段cargo new
するのと同じようにcargo component new
をします。スクリプトはライブラリと似た扱いになるので--lib
をつけます。
$ cargo component new --lib <name>
これによって最低限の骨格プロジェクトができます。この中でsrc/bindings.rs
がWITを読み取って自動生成されたコードになります。WITがどこにあるかはCargo.toml
のpackage.metadata.component.target
にかかれています。
コンポーネント本体は以下のように実装します。
-
bindings::Guest
を実装した型を用意する - 上記の型を
bindings::export!
でexportする
このあたりの詳細は実際に書いたものを見るのが早いかと思います(WIT側で定義したQuaternionとmathライブラリ側との変換関数がありますが、それ以外はここで説明したものになります)。
WASM Component Modelを使う
ここからはホスト側の実装の話になります。今回はホスト側もRustです。
実際のコードはsrc/lib.rs
にありますが、描画関連のコードもあってちょっと長いのでWASM周りに絞ってかいつまんで書いていきます。
RustでWASMを取り扱う場合はwasmtime
を使います。
wasmtimeでComponent Modelのサポートを有効にするにはcomponent-model
featureを有効にします。また、デフォルトではランタイム周りはオプトアウトされているのでruntime
featureも同時に有効にします。
WASMランタイムの初期化
wasmtime::Engine
がランタイムエンジンなので、まずはこれを初期化します。
pub fn new() -> Self {
let engine = wasmtime::Engine::default();
Self {
engine,
update_subscriptions: Arc::new(RwLock::new(HashSet::new())),
delta_time_seconds: Arc::new(RwLock::new(0.0)),
instance_map: HashMap::new(),
}
}
今回の試作では色々他にも管理すべき情報があるため複雑になっていますが、ランタイムエンジン本体の初期化はwasmtime::Engine::default()
のみです。他の初期化項目はそれぞれ以下の用途になっています。
-
update_subscriptions
: ゲームのupdateタイミングでメッセージを投げるべきコンポーネントインスタンスのID(後述)と、そのメッセージIDのペアのセット -
delta_time_seconds
: 前回のupdateからの差分時間(秒)をスクリプトから参照する用 -
instance_map
: コンポーネントインスタンスのIDと実体のペア。コンポーネントインスタンスは複数存在する可能性があるため、UUIDv7をIDとしてマップ構造で管理している
WASMバイナリのロードとコンポーネントの実体化
エンジンが初期化できたら、次はWASMバイナリをロードして外部環境とリンクしてコンポーネントを実体化します。
let mut scripting_engine = ScriptingEngine::new();
let script_path = std::env::current_dir().unwrap().join(
"../../../peridot-wasm-scripting-test/script/target/wasm32-unknown-unknown/release/script.wasm",
);
println!("script path: {}", script_path.display());
let script_bin = std::fs::read(script_path).expect("Failed to load script");
let script_component =
wasmtime::component::Component::new(&scripting_engine.engine, &script_bin)
.expect("Failed to create script component");
let mut linker = wasmtime::component::Linker::new(&scripting_engine.engine);
let mut engine_instance = linker
.instance("peridot:core/engine")
.expect("Failed to create external instance");
// ...ホスト側提供関数(engineインターフェイス他)の関数定義...
let id = Uuid::now_v7();
let resource_table = ResourceTable::new();
let mut script_component_instance_store = wasmtime::Store::new(
&scripting_engine.engine,
ScriptComponentInstanceState {
id,
update_subscriptions_ref: scripting_engine.update_subscriptions.clone(),
delta_time_seconds: scripting_engine.delta_time_seconds.clone(),
resource_table,
},
);
let script_component_instance = linker
.instantiate(&mut script_component_instance_store, &script_component)
.expect("Failed to instantiate script component");
ちょっと長いので順番に説明します。
まずコンポーネントですが、WASMバイナリを読み込んだあとにwasmtime::component::Component::new
にわたすことで生成します。
続いてコンポーネントを実体化するため必要な外部環境をリンクする必要があります。これはwasmtime::component::Linker
を使い、このLinkerに対してimportで宣言されている各種関数やインターフェイスのグローバルインスタンスを追加します。
グローバルインスタンスの実装
インターフェイスのグローバルインスタンスはwasmtime::component::Linker::instance
で追加/取得できます。ここで指定する名前は<WITのpackage名>/<interface名>
の形になります。
インスタンスが取得できたら、それに対してfunc_wrap
を呼ぶことでRustの関数をラップして追加できます。
// シンプルな例
engine_instance
.func_wrap("log", |_store, (text,): (String,)| {
println!("script log: {text}");
Ok(())
})
.expect("Failed to register global func");
// もうちょっと複雑な例
engine_instance
.func_wrap("cube-transform", {
// 先にRenderer側が持っているTransformコンポーネントのArcをコピーしておく
let entity_ref = renderer.cube_transform_component.clone();
move |mut store: StoreContextMut<ScriptComponentInstanceState>, _params: ()| {
// インスタンスごとのResourceTableに、ホスト側のコンポーネント参照をラップしたresourceインスタンスを登録し、そのハンドルを返している
Ok((store
.data_mut()
.resource_table
.push(ScriptingEngineTransformComponentRefResource {
entity_ref: entity_ref.clone(),
})
.unwrap(),))
}
})
.expect("Failed to register engine export fn");
関数に渡される引数のうち、第一引数は呼び出し元のコンポーネントインスタンスの状態コンテキストで、この引数を使ってコンポーネントインスタンスごとの状態にアクセスすることなどができます。
第二引数はそのままパラメータで、タプルでわたってきます。
返り値はそのままコンポーネントインスタンス側に渡されます。void(()
)以外の値を返す場合は(一つであっても)タプルで返す必要があります。
また、例ではしれっとresourceのインスタンスを返しています。WITのところで説明した通り、resourceインスタンスは実装上は「ハンドル」の形でやり取りされるため、このハンドルを何らかの方法で生成してresourceインスタンスと紐づける必要があります。
そこで使用するのがwasmtime::component::ResourceTable
で、これは自前で何らかの形で持っておく必要があります(特にwasmtime::Engine
が勝手に用意してくれるとかではない)。
resourceの実装
残るはresourceの実装です。
resourceの実装は、まずホスト側で実体となる構造体を定義して、それをresourceとしてLinkerまたはグローバルインスタンスに定義します。
pub struct ScriptingEngineTransformComponentRefResource {
entity_ref: Arc<RwLock<CubeTransformComponent>>,
}
engine_instance
.resource(
"transform-component",
ResourceType::host::<ScriptingEngineTransformComponentRefResource>(),
|_state, _handle| Ok(()),
)
.expect("Failed to register transform-component resource");
resourceのメソッドの実体の定義ですが、これがちょっとややこしくて[method]<resource名>.<method名>
といったフォーマットの名前の関数をresource定義と同階層に定義することで紐づくようになっています[1]。
engine_instance
.func_wrap(
"[method]transform-component.rotation",
|store: StoreContextMut<ScriptComponentInstanceState>,
(this,): (Resource<ScriptingEngineTransformComponentRefResource>,)| {
Ok((ScriptingEngineMathQuaternion::from(
store.data().resource_table.get(&this).unwrap().rotation(),
),))
},
)
.expect("Failed to set transform-component.rotation function impl");
engine_instance
.func_wrap(
"[method]transform-component.set-rotation",
|store: StoreContextMut<ScriptComponentInstanceState>,
(this, rot): (
Resource<ScriptingEngineTransformComponentRefResource>,
ScriptingEngineMathQuaternion,
)| {
store
.data()
.resource_table
.get(&this)
.unwrap()
.set_rotation(rot.into());
Ok(())
},
)
.expect("Failed to set transform-component.set-rotation function impl");
メソッドの場合、パラメータの第一引数が呼び出し元のresourceインスタンスのハンドルそのものになります。通常の関数との違いはここだけです。
ちなみにsrc/lib.rs
ではWITで宣言されたメソッドの一部のみ定義していますが、これでもリンクは通ります(実行時になければエラーになるとかだった気がします)。
コンポーネントインスタンスの状態ストアの作成
コンポーネントを実体化するために最後に一つ、コンポーネントインスタンスに固有の状態を格納するための状態ストア(wasmtime::Store
)を作る必要があります。状態の型はなんでもよく、今回は以下のようなプレーンな構造体を状態として持たせています。
pub struct ScriptComponentInstanceState {
/// コンポーネントインスタンスのID
id: Uuid,
/// update時に通知するインスタンスIDとメッセージIDのペア subscribe-updateの処理時にScriptingEngine内のこれが参照できる必要があるため持たせている
update_subscriptions_ref: Arc<RwLock<HashSet<(Uuid, u32)>>>,
/// 前回updateからの差分時間(秒) これも上記と同じ理由で持たせている
delta_time_seconds: Arc<RwLock<f32>>,
/// このコンポーネントのResourceTable
resource_table: ResourceTable,
}
ここまで作ったオブジェクトをwasmtime::component::Linker::instantiate
にわたすことでコンポーネントを実体化できます。
コンポーネントインスタンスの関数呼び出し
これは実体化に比べれば簡単で、以下のように関数を取得して呼び出して後処理するだけです。
let ep_func = script_component_instance
.get_typed_func::<(), ()>(&mut script_component_instance_store, "entrypoint")
.expect("no entrypoint defined?");
ep_func
.call(&mut script_component_instance_store, ())
.expect("Failed to call entrypoint");
ep_func
.post_return(&mut script_component_instance_store)
.expect("Failed to post-return entrypoint");
最低限今回の試作に関係する部分のみですが、以上でWASMコンポーネントを動かすことができます。
おまけ: recordのホスト側定義
WIT内のrecordに対応するホスト側の型はstructで定義できますが、いくつかのtraitをderiveする必要があります。
まず必要になるのはComponentType
traitで、これをもってComponent Modelで使える型であることを明示します。必須オプションとして、component
アトリビュートでWITでの具体的な型の種類(record
やenum
など)を指定します。
続いてComponent Model ABIとの変換を可能にするためにLift
traitおよびLower
traitのderiveが必要です。これはそれぞれ以下のような役割を果たし、特に理由がない場合は両方指定します。
-
Lift
: Component Model ABIからネイティブ(Rustで扱う)表現への「上げ」変換 -
Lower
: ネイティブ表現からComponent Model ABIへの「下げ」変換
基本的にはこの3つを常にderiveします。例えば今回のquaternion
recordのホスト側定義は以下のようになります。
#[derive(ComponentType, Lower, Lift)]
#[component(record)]
pub struct ScriptingEngineMathQuaternion {
x: f32,
y: f32,
z: f32,
w: f32,
}
ホスト-スクリプト間の動き
最後に、今回のスクリプティング環境がどういう形でホスト側とやり取りしているかを簡単に書いて終わります。
大枠の動き方としては先に書いた通り、以下の順番で動作します。
- ホスト: WASMバイナリをロードし、コンポーネントを実体化する
- ホスト: コンポーネントインスタンスの
entrypoint
関数を呼ぶ - スクリプト: 初期化処理を行う。必要であれば
subscribe-update
を呼び、ホスト側にupdateタイミングで通知してほしいメッセージIDを登録する - ホスト: updateループ
- ホスト:
subscribe-update
の登録リストを見て、コンポーネントインスタンスに対してメッセージを投げる - スクリプト: メッセージを受け取り、必要な処理を行う
- ホスト: 各種内部処理を行いレンダリングコマンドを発行する
- ホスト:
ホスト側リソース(今回の場合はcubeのtransform情報)へのスクリプトからのアクセスは、resourceに紐づいたラッパーオブジェクトを経由して行います。
- スクリプト:
cube_transform
を呼び、ホスト側にresourceをリクエスト - ホスト: Renderer内のcubeのtransform情報への参照カウントを増やし、resourceに紐づいたラッパーオブジェクトを作成
- ホスト: ラッパーオブジェクトをResourceTableに登録してハンドルをスクリプト側に返す
- スクリプト: 受け取ったresourceインスタンスのメソッドを呼び出す(ここでは仮に
set-rotation
を呼んだとする) - ホスト:
[method]transform-component.set-rotation
に紐づいた関数が実行される。ResourceTableから、該当resourceインスタンスに紐づいたホスト側のラッパーオブジェクトへの参照を取り、中身を更新する
Discussion