はじめに
WebAssembly(以下wasm)はJS以外の言語で書かれたプログラムをいい感じにwebブラウザ等で扱ったりするための...なんかそういう感じのアレです。
現状wasmを使用する一番の利点は他言語で書かれたライブラリの読み込みを早くできることらしいですが、一応低水準言語なのでJSよりも実行速度が早い(早くなる)ことが期待されます。
そんなwasmをphina.jsでなにかに使えないかと考えてみました。しかし2Dのゲームとなると計算の高速化を利用できる部分というのは限られてる気がします。
phina.js内部で特に処理が重くなりそうなのは以下の2つあたりだろうかと思われます。
- オブジェクトの最終的な位置や角度など決める行列計算処理
- filter機能
1は馬鹿みたいにオブジェクトを出さなければ必ずしも処理を重くなるわけでもなく、またコア部分に近い処理になるのでちょっと処理を挟むのが難しそうです。
2はピクセル一つ一つに処理を行う関係上、まあまあ重い処理になります。
たとえ128x128の画像の場合、ピクセル数の分、つまり12128回ループを回す必要があります。こちらのほうは処理が噛ませやすいかも?ということでwasmに任せられるか試してみました。
(これもwebGLでやったほうがよくね?案件だけど一種のロマンということで…)
環境・前提
- wasm側の言語にはrustを使いました。理由は…あまりなくて、個人的に触れてみたかっただけ。C++とかでも考え方は同じ。
- けど拙者基本JSしか分からぬ侍エンジ○ア塾ですので無駄なことをしている可能性が大いにあります。
- rustのwasmコンパイル、環境構築の話はここではしません(できないとも言う)。自分はこちらの記事を参考に構築しました。
アプリとかバージョンとか
- GNU make 3.82.90
- rustc v1.29.2 (cargo v1.29.0)
- phina.js v0.2.2
- wasmが実行できる最近のブラウザ
何をするか・その流れ
filter処理で映像をこのようにリアルタイムでセピア化してみます。
流れ
- wasmモジュールをロード
- JSとwasmでシェアする配列(後述)用メモリ領域を確保
- phina側で普通に描画処理
- 処理後、描画結果をimageDataとして取得する
- imageDataの中身をシェア配列にコピー
- ポインタをwasm側に渡してシェア配列にフィルター処理開始
- シェア配列をimageDataのソースとして使う
- 完成!
コード解説
wasmサイド
wasm側のrustコードはそんなに長くないので全文をのせます。
use std::mem;
use std::os::raw::c_void;
use std::slice;
/* メモリ確保する&ポインタの位置を返す */
#[no_mangle]
pub extern "C" fn alloc(size: usize) -> *mut c_void {
let mut buf = Vec::with_capacity(size);
let ptr = buf.as_mut_ptr();
mem::forget(buf); // 意図的にdrop処理を無効にしてメモリを開放させない
return ptr as *mut c_void;
}
/* メモリ開放処理 */
#[no_mangle]
pub extern "C" fn dealloc(ptr: *mut c_void, cap: usize) {
unsafe {
let _buf = Vec::from_raw_parts(ptr, 0, cap);
}
}
/* フィルター加工処理 */
// 割り算で小数点以下を扱うため型はfloat
const COLOR_SUM: f32 = 765.0;
const SEPIA_R: f32 = 240.0;
const SEPIA_G: f32 = 200.0;
const SEPIA_B: f32 = 118.0;
#[no_mangle]
pub extern "C" fn filter(pointer: *mut u8, max_width: usize, max_height: usize) {
let pixel_num = max_width * max_height;
let byte_size = pixel_num * 4;
let sl = unsafe { slice::from_raw_parts_mut(pointer, byte_size) };
for i in 0..pixel_num {
let r = sl[i * 4] as f32;
let g = sl[i * 4 + 1] as f32;
let b = sl[i * 4 + 2] as f32;
let avg = (r + g + b) / COLOR_SUM;
sl[i * 4] = (SEPIA_R * avg) as u8; // new r
sl[i * 4 + 1] = (SEPIA_G * avg) as u8; // new g
sl[i * 4 + 2] = (SEPIA_B * avg) as u8; // new b
}
}
メモリの確保・開放処理に関しては決まり文句的な感じです。
フィルター処理についてはJS側とシェアしてる配列(後述)のポインタを取得してピクセルの数の分、セピア化処理を回していく感じです。
セピア化の考え方はこちらを参考にしました。
JS(phina)サイド
Wasm loaderの用意
wasmモジュールは初期化の際に動的にバイナリを読み込む必要があります。
なのでAssetLoaderを拡張してwasmバイナリを読み込めるようにします。
やってることはjson読み込みなどの応用で、特筆すべきポイントは特にありません。
コードはこちら。
(余談ですが、wasmが実行できる環境はまぁまぁ最新だろうということでfetchなどの新し目の機能を特にトランスパイルせず使ってます。)
これで他アセットと同様wasmモジュール読み込みを行うことができます。
var app = phina.game.GameApp({
startLabel: 'main',
backgroundColor: "#30CEE0",
assets: {
image: {
tomapiko: "assets/tomapiko_ss.png",
},
wasm: {
"filter_mod": {
path: './wasm/main.wasm',
imports: {}
}
}
}
});
※JS側のメソッドをwasm側で扱うこともでき、importsはそのためのオプションですが、今回は使わないので空のまま
ついでに利便のため、phina.util.WasmModuleというラッパークラスを用意しています。
メモリ空間のシェア
var mod = this.wasmModule = phina.util.WasmModule("filter_mod").exports;
var width = this.width;
var height = this.height;
/* wasm sharing array setup */
var byteSize = width * height * 4; // canvas memory size
var ptr = this.ptr = mod.alloc(byteSize); // pointer
this.usub = new Uint8ClampedArray(mod.memory.buffer, ptr, byteSize); // wasm側バッファ上の配列を参照
this.filteredImageData = new ImageData(this.usub, width, height); // フィルター加工後のimageData
wasmモジュールを取得した後、allocメソッドを行ってメモリ領域をどこかに確保します。「どこか」の場所の先頭部(ポインタ)は返り値として取得できるので保持しておきます。
そしてwasmとJSの重要な架け橋となるのがthis.usub
と定義している配列です。
この配列はJS/wasmで互いにシェアしている状態になっているので操作するとwasm側でも変更が適用されます。(逆も然り)
便宜上、こいつを「シェア配列」と呼びます。
filteredImageDataはフィルター加工後にセットするためのcanvas用のイメージデータです。見ての通りthis.usubを参照しているのでシェア配列をいじるとその影響を受けます。
確保するバイト数について
シェア配列には色情報(RGBA値)を格納します。
this.usub = [R値, G値, B値, A値 , R値, G値, .... ]
R、G、B、A値いずれも0 ~ 255の範囲、つまり8bitのUnsigned intのため、配列の型はUint8ClampedArrayとなります。(というかimageDataのデータソースとして使うにはこの型じゃないとダメだった)
8bit = 1byteになるのでピクセル一つに付き、1+1+1+1 = 4byteの大きさになります。従ってcanvas全体のメモリサイズは
canvasの幅 * canvasの高さ * 4
となりますので、この分のメモリ領域を確保しています。
描画結果を取得してフィルターをかける
phina.jsのScene描画は現在のSceneクラスのもつ、_renderというメソッドが毎フレーム実行されることで実現しています。
この_renderがrendererを走らせてsceneのもつcanvasへの描画を行いますが、描画の完了をスマートに検知する方法は(多分)ないので、やや乱暴ですが_renderをオーバーライドしてrenderer処理後にフィルター処理を割り込ませます。
_render: function() {
this.renderer.render(this);
if (this.isFiltered) this.filter(); // 追加
}
filter処理は以下のような感じ。だいたいコメントの通りです。
canvas.contextのgetImageData/putImageDataを使って描画結果をうまいこと加工します。
filter: function() {
var ctx = this.canvas.context;
var mod = this.wasmModule;
var currentImage = ctx.getImageData(0, 0, this.width, this.height); // 現在のsceneのcanvas描画結果を取得
this.usub.set(currentImage.data); // wasm側に一旦コピー
mod.filter(this.ptr, this.width, this.height); // wasmフィルター処理
ctx.putImageData(this.filteredImageData, 0, 0); // フィルター後のイメージを適用する
},
これで以下のような結果となります。
タップでフィルターを外したり適用したりできるはずです。
(おまけ)メモリ解放
allocした領域はJSのようにGCで開放されたりしないので、放っておくとメモリリークします。
なのでsceneを抜けたらメモリを開放します。
this.on('exit', function() {
mod.dealloc(ptr, byteSize);
});
知見
今回は試してみただけで、素のJSだけと比べてどっちがいいかちゃんと検証してないです。気分が乗ったら、パフォーマンス比較もやってみるかもしれません。
多分この程度だったらあまり変わらないのでは、という印象です。むしろJSのみでやるよりCPUが唸りがちなのでちょっと怖い。
(あと書いた後気づきましたが、phina.jsのfilter機能はあまり関係なかったですね...)