VueベースなWebツールのロジック部分をRust製Wasmで実装する

6 min read読了の目安(約5800字

こんにちは、複雑な模様に癒しを感じるWebプログラマのpirosukeです。

今回はWebフロントエンド側で複雑なロジックを実装する際、Wasm形式で作成しておけば
そのロジックをサーバ側やコマンドで使いたくなったときに使い回しがしやすいのでは?
と思い立ってVueとWasmを組み合わせて使う方法を調べてみました。

Webに依存するUI部分はVueに任せて、Webに依存しないロジック部分をRustで作成したWasmに持たせる形でWebアプリを作ってみます。

Webスピログラフを作る

今回は私が愛してやまない「スピログラフ」をブラウザで楽しむことができる「Web版スピログラフ」というものを作ってみました。

スピログラフというのは、下記のWikipediaに説明があるような、円形の幾何学模様を描くことができるプラスチック製のおもちゃです。

https://ja.wikipedia.org/wiki/スピログラフ

ファミレスのお子様セットのおまけについていたり、100円ショップで売っていたりするので知っている方は多いかもしれません。

円形の枠に小さな穴の開いた円形の歯車を入れ、鉛筆を歯車の穴に差し込んでグルグル回すと、曲線を組み合わせた綺麗な幾何学模様が出来上がっていきます。
自分で描くのも楽しいですが見ているだけでも楽しいので、これを自動生成してボーッと眺めていられるアプリを作ってしまおうというわけです。

こんな感じの模様を作ります。

https://twitter.com/pirosuke/status/1360441159068209153

スピログラフで描かれる曲線は「内トロコイド」と呼ばれ、下記のページに記載されている式を使って表すことができます。

https://ja.wikipedia.org/wiki/トロコイド

今回はこの式を使って座標を算出する関数をWasmに定義しておき、Javascriptからその関数を呼び出してcanvas上に内トロコイド曲線を描画していく形で実装します。

スピログラフの魅力は、歯車の大きさを変えたり、穴の位置を変えたりすることで出来上がる曲線が変わるところなので、それも楽しめるようにVueとVuetifyで各円の半径や色を変更できるようにします。

完成例がこちら:

https://pirosuke.github.io/tools/spirograph/

完成例のソースがこちら:

https://github.com/pirosuke/spirograph-rs

処理内容自体は本来Wasmを使うほどでもないシンプルなものなのでソースを見ていただくとして、ここからはコードの中身よりもVueからWasmを使用する際の構成をメインに説明していきます。

アプリの構成

今回作成するアプリの構成とWasm関連ファイルは下記のようになります:

project_root
| pkg             - wasm-packのビルド結果が格納されるディレクトリ
| public          - 最終ビルド結果が格納される公開用ディレクトリ
| src             - Rustソースディレクトリ
| src_vue         - Vueソースディレクトリ
  | store
    | modules
      | spirograph.js - Wasmの関数を使用するVuex用コード
  | vue.config.js - Vueビルド定義ファイル
| Cargo.toml      - Rustビルド定義ファイル

RustでWasmを開発する環境を準備する

まずはRust側の開発環境を準備しましょう。
Rustの開発環境はインストール済みであることとします。

初めての方はこちらからどうぞ:

https://doc.rust-jp.rs/book-ja/

まずはアプリ用のRustライブラリプロジェクトを作成します。

cargo new --lib spirograph

下記のようにフォルダとファイルが作成されているはず。

spirograph
| src             - Rustソースディレクトリ
  | lib.rs
| Cargo.toml      - Rustビルド定義ファイル

Cargo.tomlに下記の設定を追加します。

[lib]
crate-type = ["cdylib"]

[dependencies]
wasm-bindgen = "0.2"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

serde系ライブラリは必須ではないですが、
JavascriptとWasmの間でやりとりする際にJSONを使いたいのでnode追加しておきます。

続いてlib.rsに処理を書くわけですが、とりあえず覚えておくべきなのはJavascript側から呼びたい関数に「wasm_bindgen」マクロを設定しておく、ということです。

下記のように書けば関数「calc_point」がJavascript側から呼び出し可能となります。

use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub fn calc_point(setting_json: &str, angle: f64) -> String {
    //算出した座標を返す処理
}

Wasm用のコードが書けたら、RustプログラムからWasmファイルを生成するためのツールをインストールします。

RustでWasmを開発するためのツールはいくつかあるようですが、
今回は「wasm-pack」を使用します。

下記のURLに記載の方法に従ってインストールします。

https://rustwasm.github.io/wasm-pack/installer/

wasm-packコマンドで作成したコードをビルドしてらwasmファイルを生成することができます。

wasm-pack build

デフォルトだと「pkg」という名前のディレクトリが作成され、
そこにwasmファイルとそれをJavascriptから使用するためのJSファイルが生成されます。

spirograph
| pkg             - wasm-packのビルド結果が格納されるディレクトリ
| src             - Rustソースディレクトリ
| Cargo.toml      - Rustビルド定義ファイル

Vueプロジェクトを作成してビルド環境を整える

VueプロジェクトはVue CLIで生成します。

https://cli.vuejs.org/guide/

作成したRustプロジェクト内で「src_vue」という名前でVueプロジェクトを作成します。

spirograph
| pkg             - wasm-packのビルド結果が格納されるディレクトリ
| src             - Rustソースディレクトリ
| src_vue         - Vueソースディレクトリ
  | vue.config.js - Vueビルド定義ファイル
| Cargo.toml      - Rustビルド定義ファイル

VueのビルドとWasmのビルドを別々に行うのは面倒なので、Vueのビルド設定ファイルにWasmをビルドする処理を追加しておきましょう。

まずは必要なライブラリを追加します。

npm install --save-dev @wasm-tool/wasm-pack-plugin
npm install --save text-encoding

vue.config.jsにwasmビルド用の処理を追加します。

const WasmPackPlugin = require("@wasm-tool/wasm-pack-plugin");
const webpack = require("webpack");
const path = require("path");

module.exports = {
  chainWebpack: config => {
    // rust wasm bindgen https://github.com/rustwasm/wasm-bindgen
    config
      .plugin("wasm-pack")
      .use(WasmPackPlugin)
      .init(
        Plugin =>
          new Plugin({
            crateDirectory: path.resolve(__dirname, "../pkg")
          })
      )
      .end()
      //  needed for Edge browser https://rustwasm.github.io/docs/wasm-bindgen/examples/hello-world.html
      .plugin("text-encoder")
      .use(webpack.ProvidePlugin)
      .init(
        Plugin =>
          new Plugin({
            TextDecoder: ["text-encoding", "TextDecoder"],
            TextEncoder: ["text-encoding", "TextEncoder"]
          })
      )
      .end();
  }
};

これでnpm run buildでwasmのビルドもしてくれる環境が整いました。

JavascriptからWasmの関数を呼ぶ

Vue側のどこからでもWasmの関数を呼び出すことができますが、
イメージ的にVuexのstateと連動したいケースが多そうなので、
Vuexから呼ぶ形で作ってみます。

let spiroAPI

export default {
    namespaced: true,
    state: {
        point: {
            x: 0,
            y: 0,
        },
    },
    mutations: {
        setPoint(state, payload) {
            state.point = payload.point
        },
    },
    actions: {
        async loadWasm(context) {
            // ここでWasmとグルーJSをロードしている
            const wasm = import("../../../../pkg")
            spiroAPI = await wasm
        },
        async calcNewPoint(context, payload) {
            if (spiroAPI == undefined) {
                await context.dispatch('loadWasm')
            }

            // ここでWasm内の関数を呼んでいる
            const newPoint = await spiroAPI.calc_point(JSON.stringify(payload.settings), payload.angle)
            context.commit("setPoint", {
                point: JSON.parse(newPoint),
            })
        },
    },
}

wasmファイルは非同期で読み込む必要があるので、loadWasmという関数を作成して
使用する前にロードするようにしています。

wasmとのデータのやりとりにはオブジェクトを一旦JSON文字列に変換して渡し、
wasm側でそれをオブジェクトに戻して使用しています。
同じくwasmから返している値もJSON文字列で返してJavascript側でオブジェクトに戻して使用しています。

あとはWasmから返された座標をVue側で頑張ってcanvasに反映すれば良い感じで内トロコイド曲線が表示されるはずです。

おわり

ここまで仕組みが実装できたら、あとはWasm内の処理を追加したり関数を追加したりすることで機能を増やしていけるはずです。

Vueだけで完結する場合と比較するとちょっと手順は増えますが、Rustで書いたコード資産をWebフロントエンドで使いまわせるのは嬉しいですね。