🎮

WASM Component Modelでゲームをスクリプティングしてみる

2024/12/02に公開

https://x.com/Pctg_x8/status/1810677149952864299
これの話をします

試作リポジトリ: 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で指定したidon-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.tomlpackage.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がランタイムエンジンなので、まずはこれを初期化します。

src/lib.rs(ScriptingEngineのnew 559行目~)
    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バイナリをロードして外部環境とリンクしてコンポーネントを実体化します。

src/lib.rs(709行目~一部)
    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の関数をラップして追加できます。

func_wrap例
    // シンプルな例
    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またはグローバルインスタンスに定義します。

src/lib.rs(transform-component resourceの実体型の定義 583行目~)
pub struct ScriptingEngineTransformComponentRefResource {
    entity_ref: Arc<RwLock<CubeTransformComponent>>,
}
src/lib.rs(transform-component resourceの定義 783行目~)
    engine_instance
        .resource(
            "transform-component",
            ResourceType::host::<ScriptingEngineTransformComponentRefResource>(),
            |_state, _handle| Ok(()),
        )
        .expect("Failed to register transform-component resource");

resourceのメソッドの実体の定義ですが、これがちょっとややこしくて[method]<resource名>.<method名>といったフォーマットの名前の関数をresource定義と同階層に定義することで紐づくようになっています[1]

src/lib.rs(801行目~)
    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)を作る必要があります。状態の型はなんでもよく、今回は以下のようなプレーンな構造体を状態として持たせています。

src/lib.rs(545行目~)
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にわたすことでコンポーネントを実体化できます。

コンポーネントインスタンスの関数呼び出し

これは実体化に比べれば簡単で、以下のように関数を取得して呼び出して後処理するだけです。

src/lib.rs(845行目~)
    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での具体的な型の種類(recordenumなど)を指定します。

続いてComponent Model ABIとの変換を可能にするためにLift traitおよびLower traitのderiveが必要です。これはそれぞれ以下のような役割を果たし、特に理由がない場合は両方指定します。

  • Lift: Component Model ABIからネイティブ(Rustで扱う)表現への「上げ」変換
  • Lower: ネイティブ表現からComponent Model ABIへの「下げ」変換

基本的にはこの3つを常にderiveします。例えば今回のquaternion recordのホスト側定義は以下のようになります。

src/lib.rs(634行目~)
#[derive(ComponentType, Lower, Lift)]
#[component(record)]
pub struct ScriptingEngineMathQuaternion {
    x: f32,
    y: f32,
    z: f32,
    w: f32,
}

ホスト-スクリプト間の動き

最後に、今回のスクリプティング環境がどういう形でホスト側とやり取りしているかを簡単に書いて終わります。

大枠の動き方としては先に書いた通り、以下の順番で動作します。

  1. ホスト: WASMバイナリをロードし、コンポーネントを実体化する
  2. ホスト: コンポーネントインスタンスのentrypoint関数を呼ぶ
  3. スクリプト: 初期化処理を行う。必要であればsubscribe-updateを呼び、ホスト側にupdateタイミングで通知してほしいメッセージIDを登録する
  4. ホスト: updateループ
    1. ホスト: subscribe-updateの登録リストを見て、コンポーネントインスタンスに対してメッセージを投げる
    2. スクリプト: メッセージを受け取り、必要な処理を行う
    3. ホスト: 各種内部処理を行いレンダリングコマンドを発行する

ホスト側リソース(今回の場合はcubeのtransform情報)へのスクリプトからのアクセスは、resourceに紐づいたラッパーオブジェクトを経由して行います。

  1. スクリプト: cube_transformを呼び、ホスト側にresourceをリクエスト
  2. ホスト: Renderer内のcubeのtransform情報への参照カウントを増やし、resourceに紐づいたラッパーオブジェクトを作成
  3. ホスト: ラッパーオブジェクトをResourceTableに登録してハンドルをスクリプト側に返す
  4. スクリプト: 受け取ったresourceインスタンスのメソッドを呼び出す(ここでは仮にset-rotationを呼んだとする)
  5. ホスト: [method]transform-component.set-rotationに紐づいた関数が実行される。ResourceTableから、該当resourceインスタンスに紐づいたホスト側のラッパーオブジェクトへの参照を取り、中身を更新する
脚注
  1. そういう仕様らしい(https://zenn.dev/tanishiking/scraps/7aa5bcbd6902c2#comment-c2572bb1bed867 の一番下 および https://github.com/WebAssembly/component-model/blob/main/design/mvp/Explainer.md#import-and-export-definitions 参照) ↩︎

Discussion