WebAssemblyとAssemblyScriptの導入を考える

公開:2018-02-12 12:30
更新:2020-02-15 04:37
カテゴリ:シューティングゲーム,ゲーム製作,HTML5,ES6,JS,Blender,WebGL2.0,WebAssembly,AssemblyScript

2Dスプライトエンジンもとりあえず完成し、続いて元のゲーム・コードに統合しようかなと思ったが、WebAssemblyがメジャーブラウザでは既定で有効化されていることを知り、使えないか少し掘り下げて調べていた。

WebAssembly(wasm)

WebAssemblyはWebにおけるバイナリ・フォーマットの標準案であり、目指しているのはコードの事前コンパイルによるコード・サイズの圧縮、サーバーとUA間のロード時間短縮、UA側のコンパイル時間の短縮、そしてUA側の処理パフォーマンスの改善(ネイティブコード並み)である。今のところMVPというバージョンがリリースされ、各ブラウザ(Chrome/Firefox/Edge)に実装されている。

WebAssemblyのバイナリ・コードはNaClのような制約を設けたネイティブ・バイナリではなく、WebAssemblyの仕様で定義される仮想マシン用のバイトコードである。仮想マシンはJAVAやCLRやPNaClと同じスタック・マシンであり、インストラクション・セットはかなりコンパクトなものになっている。これをUA側で受け取った後、コンパイルを行い、環境に応じたネイティブ・コードに変換され実行される。

現状のMVPバージョンでは、どちらかというとコードのコンパクト化・コンパイル時間の短縮、つまりコンテンツのロードから実行に至るまでの時間短縮に主眼を置いており、UA側の最適化はまだこれからのようである。またGCは実装されておらず、次のレベルで実装されるようである。将来的にはSIMDのサポートやスレッド、Web関係のAPIもWebAssemblyから直接呼べるようになるそうだ。

JSも昔と比べ、JITによる最適化が行われており、ハードウェア自体の性能向上も相まって実用上は十分なパフォーマンスがあるように思われるが、アプリケーションの複雑化によってUA側のコンパイル時間の長時間化、JS自体の最適化のむずかしさが顕在化しており、このようなソリューションが待たれていた。

WebAssemblyはまたJSとの共存も考慮した設計となっており、JSからWebAssemblyのロード・実行、WebAssemblyからJS関数の呼び出しが標準でサポートされている。つまり高速化の必要な部分はWebAssemblyで、その他の部分はJSでといった使い分けができる。

ブラウザ上でゲームを作っている私にとっては検討に値する仕様である。

ゲームへの導入検討

しかし技術の移り変わりの激しい現在、新技術に乗り換えながら開発を続けるというのは「いつまでたっても完成しない」というリスクを増加させることになる。特に私のような締め切りのない趣味プログラマであればなおさらである。ゲームを完成させる目標も自分自身との約束でしかないので、いつでも反故にすることができる。

現に私はWindows Game SDKくらいからシューティング・ゲームを作り続けてはいる(MODトラッカーやその他アプリを作ろうと試みている期間もも結構な割合を占めているが)が、APIのアップデートや新たな技術仕様に乗り換える度に完成からは遠のいていき、気が付けば20年以上経過してしまっている。いや、もっとさかのぼればMZ-700を購入したころからスクロール・シューティングは作ろうとしてたから、35年以上は経過してしまっている。いくつかは中間成果物のようなものをWeb上で公開してはいるけども。。

(一応プロのゲーム・プログラマであった時代も3年ほどあり、そこで数種類のゲーム開発に携わっており、完成・販売するところまで到達したので完成品を作れなかったわけではないが。。)

なので新技術を知るたびに

  1. 技術を固定化して早期に完成させる
  2. 完成を遅らせても新技術を導入する

について数日悩むわけである。ひょっとすると悩み続けた35年かもしれない。。

本当はこの3連休で元のゲーム・コードとここ1か月くらい書いたコードをマージして、完成に向けて進めようと思っていたところなのではあるが。今はWebAssemblyをどのようにゲームに取り入れるか考えており、どうやら私は2.を選択したようである。どうも私は新しい技術に触れることが好きなようだ。かといって深入りすることもないから、中途半端な知識レベルで終わってしまうことが常でもあるが。

それはさておき、WebAssemblyに魅力を感じる点の1つとしては「型」である。 JSとWebGLを使ってコードを書いていると気になるのが「型」変換である。

WebGLは厳密に型を要求する。JS側のAPIはそれでも型を厳密に強要(推論できるレベルには強要する)することはないのだが、シェーダー・コード側は「型」に厳密である。型で厳密というか静的型付けを要求するといったほうがいいだろうか。なのでJS側とWebGL間とのやり取りには「型の変換」という作業が必ず入る。この「型の変換」は意外にコストが高い処理であり、そのような処理にCPUリソースを割かれるのがとても気になるのである。

実はJS側にはWebGLに引き渡す頂点情報等のために、型付き配列(TypedArray)というものがある。これは型の制約が配列に入る要素に設けられたものである。このTypedArrayはArrayBufferを引数にとる。ArrayBufferはリニアなメモリをJS上で表現するための仕組みであるある。またDataViewがあり、これはArrayBufferの中身をいくつかの型で出し入れをすることができる。しかし出し入れする際にやはり型変換が発生してしまい、少なからずオーバーヘッドを抱えることになる。Typed Arrayを使用しても型変換が発生してしまう。

行列演算等はJS側で少なからず行うから、その際の変数の出し入れで型変換が起こり、CPUリソースが食われていると考えると「ぞっと」してしまうのである。(ひょっとするとJS側の実行時の最適化でそれなりに変換コストは除去されるものなのかもしれないが。またasm.jsはこのような型変換を最小化しパフォーマンスを引き上げることを実際に行っている。) が、WebAssembly側に持っていってしまえばほぼ解消させることができる。WebAssemblyは型を指定できるからだ。

しかし、WebAssemblyを直書きするのはできなくはないが時間がかかりすぎるから、コンパイルターゲットとしての利用が主になる。最初に実装されたのはC/C++(emscripten)である。静的型付け言語の「雄」といった存在であり、JSよりもWebAssemblyとの親和性が高い。しかもC/C++はコンパイラの最適化技術が最も進んでいる言語であるから、効率的なコードを出力することが期待できる。またWebGLに関しても、ライブラリによって呼出しが可能になっているようである。Unreal SDKなどはランタイムごとWebAssembly化しており、デスクトップ用に作ったゲームを比較的に容易にWeb化できるようである。

しかしC/C++からのWebAssemblyサポートを行うために巨大なランタイムをバンドルする必要があり、ロード時間を長時間化してしまうという問題がある。しかもC/C++の面倒さに疲れてJSに逃げ込み、お気楽に作ってきた私にとっては今更C/C++で作り直すなどということは考えたくもないことである。

そのようなときにAssemblyScriptの存在を知ったのである。

AssemblyScript

AssemblyScriptTypeScriptにWebAssmblyで使用できる型の制約を加え、WebAssemblyのインストランクションに直接変換されるヘルパライブラリを加えたものである。

これを使用すればTypeScriptもしくはJSで書いたコードにWebAssemblyの型を付与するだけでWebAssembly化することができる。しかしまだ開発途上のものであり、すべてのコードを移行できるわけではない。また開発者は現在一人であり、継続的に開発は進めているようだが、サポートに関しては不安が残る。

で、AssemblyScriptのコードは以下のようなものである。


// JS側の関数定義
declare function consoleLogString(out:string,length:usize) : void;

export function test():void {
  const str : string = "test";
  consoleLogString(str,str.length);
}

上記consoleLogStringはJSの関数である。これをコンパイルすると以下のようなwasmのコードが得られる。 ほぼそのままWebAssembly化されていることがわかるだろう。

(module
 (type $iiv (func (param i32 i32)))
 (type $v (func))
 (import "env" "consoleLogString" (func $as/test/consoleLogString (param i32 i32)))
 (memory $0 1)
 (data (i32.const 32776) "\04\00\00\00t\00e\00s\00t")
 (export "test" (func $as/test/test))
 (export "memory" (memory $0))
 (func $as/test/test (; 1 ;) (type $v)
  (call $as/test/consoleLogString
   (i32.const 32776)
   (i32.load
    (i32.const 32776)
   )
  )
 )
)

これを実際に呼び出す側(ホスト側)のJSコードは以下である。

  // WebAssembly側のメモリ
  let mem ;

  // コンパイル時に引き渡すオブジェクト
  // envというプロパティにエクスポートしたいものを入れる

  const exportToWasm = {
    env:{
      consoleLogString:consoleLogString,
      sin:Math.sin
    }
  };

  function consoleLogString(index) {

    // 先頭の4byte(uint32)に文字列の長さが入っている
    const length = mem.getUint32(index,true);

    // 文字列は長さの後に続けて入っている
    const array = new Uint16Array(mem.buffer,index + 4,length);
    const str = new TextDecoder('utf-16').decode(array);
    //const str = String.fromCharCode(...array);
    alert(str);
  }

  WebAssembly.instantiateStreaming(fetch("./wa/test.wasm"),exportToWasm).then(mod => {
    const test = mod.instance.exports.test;
    mem = new DataView(mod.instance.exports.memory.buffer);
    test();
  });

簡単なコードであるが、AssemblyScriptによってWebAssemblyが生成され、それを呼び出すことは簡単にできる。 もともと、TypeScriptはJSに静的型付け機能をつけたものであるし、さらにWebAssemblyで使える型の制約を加えることでWebAssemblyへの変換があまりグルーコードに頼らずできるようだ。私にとってTypeScriptは学習障壁が低く、与し易いものである。よってAssemblyScriptでJSコードをWebAssemblyに移植していくことにしようと考えている。

しかしこれはこれでちょっと悩むところではある。例えば最初glMatrixだけを移植して、それをJS側で呼ぶようにしてみようかと考えたが、これはオーバーヘッドを生む可能性が高い。なぜならば呼ぶ度に変数の変換が発生しそうだし、得られた演算結果をコピーするコスト等も発生しそうである。つまり呼出しコストが高くつきそうだ。さらにはsin,cosなどの数学関数をAssemblyScriptが持っていない。平方根はあるが、それ以外は自前で実装しなければならない。

描画コード以外全部AssemblyScript化して、JS側からrequertAnimationFrame()が呼ばれるたびにWebAssemblyの関数を呼び出して処理を行えばよさそうだが、言語サポートの不足を補うコードをかなり書かなければいけなくなりそうなので、どうしようかな..と思っているところだ。