JavaScriptはWebの共通言語として不動の地位を築いているが、CPUヘビーな処理ではパフォーマンスの壁に直面する。画像処理、3Dレンダリング、暗号計算、データ圧縮——こうしたワークロードをブラウザ上で高速に実行したい場合、WebAssembly(Wasm)が有力な選択肢となる。
WebAssemblyは、ブラウザ上で動作するバイナリ命令形式だ。CやC++、Rustなどのコンパイル言語からWasmバイナリを生成し、JavaScriptから呼び出して使用する。ネイティブに近い速度で動作し、すべての主要ブラウザでサポートされている。
筆者は過去2年間、業務で画像処理ツールやデータ変換エンジンをWasmで開発してきた。その経験をもとに、実用的な開発の流れを解説する。
Wasmの開発言語としてはRustが最も成熟したエコシステムを提供している。wasm-packとwasm-bindgenにより、RustからWasmへのコンパイルとJavaScriptとの相互運用が容易だ。
# Rustのインストール(未導入の場合)
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# wasm-packのインストール
cargo install wasm-pack
# プロジェクトの作成
cargo new --lib wasm-image-processor
cd wasm-image-processor
Cargo.tomlにWasm用の設定を追加する。
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
wasm-bindgen = "0.2"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["console"] }
serde = { version = "1.0", features = ["derive"] }
serde-wasm-bindgen = "0.6"
具体例として、画像のピクセルデータをグレースケールに変換する処理をWasmで実装する。この種のピクセル単位の処理は、JavaScriptよりもWasmで5〜10倍高速になることが多い。
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn grayscale(pixels: &mut; [u8]) {
// RGBAの4バイトずつ処理
for chunk in pixels.chunks_exact_mut(4) {
let r = chunk[0] as f32;
let g = chunk[1] as f32;
let b = chunk[2] as f32;
// 人間の目の感度に合わせた重み付け
let gray = (0.299 * r + 0.587 * g + 0.114 * b) as u8;
chunk[0] = gray;
chunk[1] = gray;
chunk[2] = gray;
// chunk[3] (alpha) はそのまま
}
}
// ビルド
// wasm-pack build --target web --release
JavaScript側からの呼び出しは以下のようになる。
import init, { grayscale } from './pkg/wasm_image_processor.js';
async function processImage(imageData) {
await init();
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
ctx.drawImage(imageData, 0, 0);
const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height);
// Wasmでグレースケール変換
const start = performance.now();
grayscale(imgData.data);
const elapsed = performance.now() - start;
console.log(\`Wasm処理時間: \${elapsed.toFixed(2)}ms\`);
ctx.putImageData(imgData, 0, 0);
}
WasmとJavaScriptの間のデータ受け渡しは、パフォーマンスのボトルネックになりやすい。Wasmは独自のリニアメモリ空間を持っており、JavaScriptとのデータ交換にはコピーが発生する場合がある。
大量のデータを扱う場合は、Wasmのメモリを直接参照する方法が効率的だ。
use wasm_bindgen::prelude::*;
use std::alloc::{alloc, dealloc, Layout};
#[wasm_bindgen]
pub fn alloc_buffer(size: usize) -> *mut u8 {
let layout = Layout::from_size_align(size, 1).unwrap();
unsafe { alloc(layout) }
}
#[wasm_bindgen]
pub fn free_buffer(ptr: *mut u8, size: usize) {
let layout = Layout::from_size_align(size, 1).unwrap();
unsafe { dealloc(ptr, layout) }
}
#[wasm_bindgen]
pub fn process_buffer(ptr: *mut u8, len: usize) {
let data = unsafe { std::slice::from_raw_parts_mut(ptr, len) };
// data に対して処理を行う
grayscale(data);
}
// JavaScript側:Wasmメモリに直接書き込み
const ptr = alloc_buffer(imageBytes.length);
const wasmMemory = new Uint8Array(memory.buffer, ptr, imageBytes.length);
wasmMemory.set(imageBytes);
process_buffer(ptr, imageBytes.length);
// 結果を読み出し
const result = new Uint8Array(memory.buffer, ptr, imageBytes.length);
// メモリ解放
free_buffer(ptr, imageBytes.length);
ただし、wasm-bindgenが提供する高レベルAPIを使えば、多くのケースでは手動メモリ管理は不要だ。スライス引数として渡せば、wasm-bindgenが自動的にコピーを処理してくれる。手動管理はパフォーマンスがクリティカルな場合にのみ検討すべきだ。
Wasmバイナリのサイズは初期ロード時間に直結する。以下の最適化手法でサイズを大幅に削減できる。
# Cargo.toml に追加
[profile.release]
opt-level = 'z' # サイズ最適化
lto = true # リンク時最適化
codegen-units = 1 # コンパイル単位を1に
panic = 'abort' # パニック時の巻き戻しコードを削除
strip = true # デバッグ情報を削除
さらに、wasm-optツールを使った後処理も効果的だ。筆者のプロジェクトでは、これらの最適化により、初期サイズ800KBのバイナリが120KBまで縮小された。gzip圧縮後は50KB程度になり、十分に実用的なサイズだ。
Wasmを本番環境で使う際のポイントをまとめる。
WebAssemblyは、ブラウザ上でのCPUヘビーな処理を高速化する実用的な技術だ。Rustとwasm-packの組み合わせにより、開発体験も大きく向上している。すべてのWeb処理をWasmにする必要はないが、パフォーマンスがボトルネックとなる箇所にピンポイントで適用することで、ユーザー体験を劇的に改善できる。まずは小さな処理から試してみることを勧める。