この記事は Web グラフィックス Advent Calendar 2021 の1日目の記事です。
発端
Flutter
仕事でAndroidの業務アプリを作ることになり、IonicまたはFlutterの二択ということになりました。筆者はAngular使いなのでIonicを選んだのですが、小心者なので、選ばなかったFlutterのことが気になってしかたなかったです。
それでFlutterについて後追いで調べていて、これは良いなと思ったのが、2DグラフィックスライブラリのSkiaをWebAssemblyにしたもの(CanvasKit)を使っているところでした。
描画をWebAssemblyにしてしまえばどのブラウザでもネイティブでも同じ描画結果にできるわけで、それはすごい安心感があります。
WebViewに依存しないのもいいですね。IonicはWebView依存なのでOSに更新が入ると何が起こるか不安で夜しか眠れないようなところがあります。
WebAssemblyでの描画は速いのか?
気になるのは実行速度です。
ベンチマークみたいのは面倒なので、Flutterのパーティクルのサンプルあたりを見たところ、「めちゃくちゃ速い」とも「めちゃくちゃ遅い」ともいえないと感じました(技術者にあるまじき役に立たない感想)。描画面積が大きいと面積に比例してFPSが下がるのがはっきり分かります。低スペックなモバイル端末だときつそうな気もしますが、解像度が低いのであれば問題ないのかもしれません。
しかし自分が知りたかったのはFlutterというよりWebAssemblyによる描画がどうなのかだったので、最小構成の描画処理を作ってみることにしました。それで速かったらなんか嬉しいし、遅かったら、遅いことが分かります。分かって何が嬉しいのかは分かりませんが、それでもいい…とりあえずプログラマーはそういう生き物だと思います。
JavaScriptで作ってみた
最小構成ということで、まずGLSLをJavaScriptで再現するプログラムを作ってみました。
作ったのは、ピクセル単位にバイトデータを計算してグラフィックスを描画するプログラムです。ラスタライザというやつです。
canvasの描画機能はほとんど使わず、描画結果を画面に表示する目的でのみcanvasを使用します。canvasに表示するバイトデータを扱うにはImageDataを利用しますので、ImageData.dataへの書き込みが主たる処理ということになります。
ここはWebAssemblyは使わないため、CodePenにしてみました。
See the Pen HTML/Canvas ImageDataのバイトデータ操作による描画(JavaScript版) by 柏崎ワロタロ (@warotarock) on CodePen.
なおGLSLはGLSL Sandboxから拝借しました。
AssemblyScriptでも作ってみた
WebAssemblyを書く方法はたくさんありますが、自分はTypeScript使いなので、TypeScript風でとっつきやすそうなAssemblyScriptで作ってみました。i32とかf64とか暗黙で型変換されるところを合わせるのが少し面倒でしたが、移植は難しくありませんでした。ImageDataにメモリをバインドするやりかたを見つけるのが一番大変だったかも。
なお、微妙に異なる二種類の描画方法で実装してみています。
- JavaScriptから渡された関数をAssemblyScriptから呼んでピクセルデータを書き込む方法
- AssemblyScriptで確保したメモリを共有し書き込む方法
下の方法のほうが若干速いです。
拡張子はtsですがAssemblyScriptの拡張子はtsが標準みたいです。
// instantiate時に渡されたモジュール
declare namespace main {
function putPixel(offset: f64, r: f64, g: f64, b: f64, a: f64): void
}
// JavaScript関数経由で描画
export function drawViaFunction(width: u32, height: u32, time: f64): void {
const pixelBytes: u32 = 4
const lineBytes: u32 = width * pixelBytes
const density: f64 = 0.05
const amplitude: f64 = 0.3
const frequency: f64 = 10.0
const scroll: f64 = 0.1
const fwidth = width as f64
const fheight = height as f64
const sin = Math.sin
const abs = Math.abs
let offsetY: u32 = 0
for (let y: f64 = 0.0; y < fheight; y += 1.0) {
const posy = y / fheight - 0.5
let offset = offsetY
for (let x: f64 = 0; x < fwidth; x += 1.0) {
const posx = x / fwidth - 0.5
// from https://glslsandbox.com/e#76927.0
const line1 = (1.0 / abs((posy + (amplitude * sin((posx + time * scroll) * frequency)))) * density) * 255
const line2 = (1.0 / abs((posy + (amplitude * sin(((posx - 0.1) + time * scroll) * frequency)))) * density) * 255
const line3 = (1.0 / abs((posy + (amplitude * sin(((posx - 0.2) + time * scroll) * frequency)))) * density) * 255
main.putPixel(
offset,
0.10 * line1 + 0.05 * line2 + 0.05 * line3,
0.05 * line1 + 0.10 * line2 + 0.05 * line3,
0.05 * line1 + 0.05 * line2 + 0.10 * line3,
255.0
)
offset += pixelBytes
}
offsetY += lineBytes
}
}
// メモリ共有で描画
export function drawToByteArray(data: Uint8Array, width: u32, height: u32, time: f64): void {
const pixelBytes: u32 = 4
const lineBytes: u32 = width * pixelBytes
const density: f64 = 0.05
const amplitude: f64 = 0.3
const frequency: f64 = 10.0
const scroll: f64 = 0.1
const fwidth = width as f64
const fheight = height as f64
const sin = Math.sin
const abs = Math.abs
let offsetY: u32 = 0
for (let y: f64 = 0.0; y < fheight; y += 1.0) {
const posy = y / fheight - 0.5
let offset = offsetY
for (let x: f64 = 0; x < fwidth; x += 1.0) {
const posx = x / fwidth - 0.5
// from https://glslsandbox.com/e#76927.0
const line1 = (1.0 / abs((posy + (amplitude * sin((posx + time * scroll) * frequency)))) * density) * 255
const line2 = (1.0 / abs((posy + (amplitude * sin(((posx - 0.1) + time * scroll) * frequency)))) * density) * 255
const line3 = (1.0 / abs((posy + (amplitude * sin(((posx - 0.2) + time * scroll) * frequency)))) * density) * 255
data[offset + 0] = clampU8(line1 * 0.10 + line2 * 0.05 + line3 * 0.05)
data[offset + 1] = clampU8(line1 * 0.05 + line2 * 0.10 + line3 * 0.05)
data[offset + 2] = clampU8(line1 * 0.05 + line2 * 0.05 + line3 * 0.10)
data[offset + 3] = 255
offset += pixelBytes
}
offsetY += lineBytes
}
}
function clampU8(value: f64): u8 {
if (value > 255) {
return 255
}
else {
return value as u8
}
}
// 型付き配列をJavaScriptと共有するために必要な型のエクスポート
export const Uint8ArrayID = idof<Uint8Array>()
function initializeWasmRender(wasmModule) {
// AssemblyScriptで実装した関数が登録されたオブジェクトを取得
wasmRender = wasmModule.exports
// WebAssembly内に画像用メモリを作成
const dataLength = canvas.width * canvas.height * 4
imageDataArrayPtr = wasmRender.__newArray(wasmRender.Uint8ArrayID, dataLength)
wasmRender.__pin(imageDataArrayPtr) // GCで開放されないように設定
// 画像用メモリをImageDataのバッファとして設定
imageDataArrayView = wasmRender.__getArrayView(imageDataArrayPtr)
viewArray = new Uint8ClampedArray(wasmRender.memory.buffer, imageDataArrayView.byteOffset, dataLength) // Uint8ClampedArrayにバッファを渡すことでメモリを参照するTypedArrayが作成されます
wasm_ImageData = new ImageData(viewArray, canvas.width, canvas.height) // ImageDataにUint8ClampedArraを渡すことでバッファを参照するImageDataが作成されます
}
js_ImageData = new ImageData(canvas.width, canvas.height)
data = js_ImageData.data
function putPixel(offset, r, g, b, a) {
data[offset + 0] = r
data[offset + 1] = g
data[offset + 2] = b
data[offset + 3] = a
}
速いのか
JavaScriptが思った以上に速かったのもあり、WebAssemblyで書いても正直いってそれほど速い気がしませんでした(WebAssemblyあるある)。Flutterのデモはもっと複雑そうなのにもかかわらず、面積が広くても同じかそれ以上にFPSが出ている感じがしますから、自分の作りが悪いかAssemblyScriptが遅いのかもしれません。もしくはsin関数が重かったりするのか…。
まとめ、将来性
まとめです。
- JavaScriptとAssemblyScriptで単純なバイトデータ操作による描画処理を実装してみた
- AssemblyScriptは使いやすかった
- 速度は期待したほどではなかった
将来については、ブラウザだけでなく、どこででもWebAssemblyを実行できるようにしようという動きが、もうけっこう進んでいるらしいです。
JVMみたいなものかなーと最初は思いましたが、開発言語にJavaしか選択肢が無かった(当時)のと違い、WebAssemblyをサポートしている言語は圧倒的に多いです。言語が多ければ利用者の人口も多いということで、多くの人が動けば世界が動くという感じで、いずれ何か大きな流れになるんじゃないかなーという感じがします。
今からWebAssemblyを使いこなせる必要は少ないかもしれませんが、これから新しい言語を学ぶならWebAssemblyを活用しやすいものを選んでおくと、将来性があるかもしれません。自分はGo、Rust、Dartは挫折しました。
それではWebグラフィックス アドベントカレンダー初日でした。
最終日までよろしくおねがいいたします。