AssemblyScript で WebAssembly を知る

毎年恒例 ゆるWeb勉強会@札幌 Advent Calendar 2022 への投稿です…!

札幌で開催しているWeb系勉強会、「ゆるWeb勉強会@札幌」の2022年アドベントカレンダーです。

過去発表内容のまとめ、発表する機会の無かったネタ、勉強会に参加したいけどできなかったので、などなど、Webに関するキーワードを中心としつつ自由に書いていってください!

毎年 WebAssembly 関連を投稿させていただいておりますが、今年は WebAssembly が理解の手助けとなるような、入門記事を書いてみたいと思います。

AssemblyScript 言語

最初に WebAssembly のおおよそですが、 プログラムソースコードをビルドすることにより Wasm バイナリー(.wasm)を出力し、このファイルを WebAssembly ランタイムに読ませることでプログラムを動作させる技術です。

この記事で取り上げる AssemblyScript 言語は、TypeScript Like なソースコードを Wasm バイナリーにビルドできるコンパイラーです。npm で簡単にツールチェインが導入でき、また WebAssembly 専用の言語となっているため、端的に理解しやすく、入門にも最適だと感じます。

The AssemblyScript Book

AssemblyScript compiles a variant of TypeScript (opens new window) (a typed superset of JavaScript) to WebAssembly (opens new window) using Binaryen (opens new window), looking like:

WebAssembly ランタイムを持つ主要ユーザのひとつはウェブブラウザーで、モバイルを含む主要 4 ウェブブラウザーで .wasm を実行可能です。また、ウェブブラウザーから JavaScript ランタイムが node.js として取り出されたように、ウェブブラウザー外で .wasm を動かす動きも近年活発です。

なお、.wasm のビルドに対応しているプログラム言語は、大きく 2種類のパターンがあり、直接 .wasm にビルドするものと、言語のインタープリタ(ランタイム)を .wasm にして、ソースコード(もしくは中間コード)をそのまま与えて動作するものに分けられます。

前者の代表は、Rust、C/C++、TinyGo などなど、そして AssemblyScript もこの仲間です。

後者は、Python(Pyodide)、Ruby、.NET/C# などが該当します。つまり、これらのインタープリタやランタイムが C/C++ であるということに立脚すると、前者の方式で Wasm ビルドできればスクリプトファイルが実行できる、というイメージです。

というわけで、本記事では WebAssembly 自身の理解の手助けとなるよう、直接プログラムが .wasm となり、ランタイムとのインターフェースもシンプルで分かりやすい AssemblyScript を使って解説を進めていきます。

サンプルプログラム

AssemblyScript/WebAssembly を使って時計を描画するプログラムをつくってみました。

次のような要素が含まれています。

  • WebAssembly から直接画面描画をすることができないので、WebAssembly ランタイムから import した、指定した位置に「点」を描画する関数を呼び出し時計を描いている。
  • 上記のような特性を逆に利用すると、ランタイム環境に依存する処理が差し替えられるため、同一 WebAssembly プログラムを、ウェブブラウザーとマイコン(M5Stack) で動作させるマルチプラットフォームの例ともなっています。

ソースコード:

https://github.com/h1romas4/m5stack-core2-wasm3-as

M5Stack Core2 With Wasm3/AssemblyScript Demo

WebAssembly プログラムとランタイムのインターフェース

WebAssembly の上のプログラムはよく「計算しかできない」と言われます。これは Wasm がサンドボックスで動作し、その外側のリソースにアクセスできないためです。

外界との接続との唯一のインターフェースは、それぞれの関数の export/import と、メモリーのポインターです。

JavaScript からみた、.wasm は引数の型が不自由な関数が追加されているように見えます。

:ランタイムに関数を公開する AssemblyScript のソースコード:

– 引数の型が u32 という見慣れない型になっている。

export function clock(x: u32, y: u32, r: u32): void {
    analogClock = new AnalogClock(x, y, r);
}

export function tick(): void {
    if(analogClock != null) {
        analogClock.tick();
    }
}

:export function が入った AssemblyScript を .wasm にビルドしたアセンブリーが出力(抜粋):

– 引数が i32 という WebAssembly の型になっている。

  (export "clock" (func 16))
  (export "tick" (func 28))

  (func (;16;) (type 1) (param i32 i32 i32)
  (func (;28;) (type 2)

そしてこの関数は JavaScript から次のように呼び出すことができます。

.wasm をロードする JavaScript のソースコード:

instance.exports.wasm に定義された関数を取得できる。

async function loadWasm() {
    const response = await fetch(new URL('../dist/app.wasm', import.meta.url));
    const responseArrayBuffer = new Uint8Array(await response.arrayBuffer());
    const wasm_bytes = new Uint8Array(responseArrayBuffer).buffer;
    let module = await WebAssembly.compile(wasm_bytes);
    const instance = await WebAssembly.instantiate(module, {
        ...createImports()
    });
    wasmExports = instance.exports;
};

.wasm でエクスポートされた関数を JavaScript からコールするソースコード:

instance.exports にエクスポートされた関数の引数に number 型を渡している。

(async function() {
    await loadWasm();
    wasmExports.clock(160, 120, 120);
    setInterval(() => {
        wasmExports.tick();
        wasmExports.__collect() // clean up all garbage
    }, 500);
})();

ポイントは .wasm 関数の引数には i32, i64, f32, f64 といった WebAssembly で使える(数値)型しかとれないことです。 JavaScript のオブジェクトのようなものや、文字列、もちろん DOM などの WebIDL の型も(現在のところ)そのまま渡して処理はできません。

AssemblyScript inherits WebAssembly’s more specific integer, floating point and reference types:

逆もしかりで、.wasm 側から JavaScript の関数を呼び出す際もこれらの引数型しかとることができません。

:JavaScript の関数を import する AssemblyScript のコード:

export declare function draw_pixel(x: i32, y: i32, color: i32): void;

export declare function をビルドした .wasm のアセンブリー(抜粋):

  (type (;1;) (func (param i32 i32 i32)))
  (import "c3dev" "draw_pixel" (func (;0;) (type 1)))

:AssemblyScript に関数を公開する JavaScript のコード:

async function loadWasm() {
    // ..snip...
    const instance = await WebAssembly.instantiate(module, {
        ...createImports()
    });
    // ..snip...
};

function createImports() {
    let imports = [];
    // ..snip...
    imports['c3dev'] = {
        'draw_pixel': (x, y, color) => {
            canvasContext.fillStyle = convertRGB565toStyle(color);
            canvasContext.fillRect(x, y, 1, 1);
        },
    // ..snip...
}

.wasm 側で import した i32 という数値型 を持つ draw_pixel 関数を呼び出し、最終的に ウェブブラウザーの Canvas に指定座標にひとつ点を描いています。マイコン動作ではこの部分が LCD に点をひとつ描く C の関数が呼び出すようにしています。

(ちなみに、点が描ければ円も線も計算で描けるということで、サンプルプログラムの時計は AssemblyScript の演算処理により全て点だけで描画を行っています)

インターフェースをつくる

以上のように、WebAssembly でプログラムをかく場合は、WebAssembly ランタイムをホストしている環境との連携インターフェースの設計が肝で、特性を考慮して、どのような処理単位にすると効率的に処理できるのかを考えていきます。

また、今回の例では用いていませんが、メモリーのポインター値を共有することで、VRAM や音声波形など大きな演算結果(配列)をインターフェースすることもでき、Wasm 内の演算結果を外部に持ち出すことができます。

このようなホストへのインターフェースの標準化の実装のひとつとして WASI があり、システム時間や乱数シード、ファイルシステムやネットワークへのアクセスが規定されています。(ただしウェブブラウザーには WASI API は実装されていません)

WASI も WebAssembly 型の関数が沢山あるだけですので、覗いてみると理解しやすいかと思います。

また、Rust の wasm-pack(wasm-bindgen) や Emscripten(C/C++) など他の WebAssembly ツールチェインは、Wasm 型との引数合わせやラップ、JavaScript の呼び出しをソースコードジェネレートなどを、自動でやってくれるものとして捉えると考えやすいです。

WebAssembly のインターフェースを知ることで周辺技術が何をしているのかが見えてきそうだな、ということで本記事は書かれました。

処理を速くしたい部分や、JavaScript 外で便利なライブラリー、、圧縮展開、画像動画音声処理、言語解析器などなどを見つけたら、「WebAssembly(型) なインターフェース」を追加して呼び出す。まずは WebAssembly の実用的なユースケースのひとつと思いますので、ぜひお試しください。

関連

https://twitter.com/h1romas4/status/1610228824607985664
https://twitter.com/h1romas4/status/1612348626248044544

コメントを残す