Next.jsでWebAssembly(AssemblyScript)を導入してパーティクルシミュレーションを実装したら遅くなった話
本記事のサマリ
Next.jsでパーティクルシミュレーションを作る際、「重い物理演算はWebAssemblyで高速化しよう」と考えAssemblyScriptを導入してみました。しかし結果は予想に反して、JavaScript版よりも大幅に遅くなってしまいました。この記事では、なぜWebAssemblyが期待通りに動作しなかったのか、その原因を整理してみました。
今回のコードは下記です。
はじめに
最近のフロントエンド開発では、「重い処理はWebAssemblyで」という話をよく耳にします。特にNext.jsのような高機能なフレームワークを使っていると、さらなるパフォーマンス向上の手段として気になる技術の一つですよね。
そこで今回、パーティクル物理演算というそれなりに計算量の多い処理でWebAssemblyを試してみることにしました。AssemblyScriptを使えばTypeScript開発者にも親しみやすいだろうと考え、JavaScript版と比較検証を行いました。
(Rustに強い憧れはありますが、今回は学習コストを優先しました😱)
ただ、結果は思惑とは真逆で、WebAssembly版の方が圧倒的に遅くなってしまったのです。
今回作ったもの
検証のために実装したのは以下のようなパーティクルシミュレーターです。
- JavaScript版とWebAssembly版の2つのバージョン
- パーティクル数を100個から10,000個まで調整可能
- 重力なし、壁との完全反射による物理演算
- パーティクル同士の衝突判定
- リアルタイムFPS表示による性能比較
シンプルな作りですが、物理演算の基本的な要素は一通り含んでいます。これなら計算量もそれなりにあるので、WebAssemblyの恩恵を受けられるはずだと思っていました。
なぜWebAssemblyを導入したのか
Next.jsでの開発に慣れてくると、どうしても「もっと高速化できないか?」という欲が出てきます。特に今回のようなパーティクルシミュレーションでは、数千個のオブジェクトに対して毎フレーム物理演算を行うため、パフォーマンスがユーザー体験に直結します。
WebAssemblyは、ブラウザで実行できる低レベルなバイナリフォーマットで、理論上はJavaScriptよりも高速に動作するとされています。Mozilla Developer Networkの公式ドキュメントでも、「JavaScriptに近いパフォーマンスでネイティブコードを実行できる」と説明されています。
計算集約的な処理では特に効果が期待できるということで、「実際にどれくらい速くなるのか?」を自分の手で確かめてみたくなったのです。
AssemblyScriptを選んだ理由
WebAssemblyを書く方法はいくつかありますが、今回はAssemblyScriptを選択しました。選択肢を比較してみると以下のような感じです。
| 言語/ツール | メリット | デメリット |
|---|---|---|
| C/C++ (Emscripten) | 最高レベルの性能、成熟したツール | 学習コスト高、ビルド設定が複雑 |
| Rust | メモリ安全性、優れた型システム | 学習コスト高、コンパイルが重い |
| AssemblyScript | TypeScript風の構文、導入が簡単 | 最適化はやや劣る |
Next.js開発者にとってAssemblyScriptの最大の利点は、TypeScriptとほぼ同じ構文で書けることです。新しい言語を学ぶ必要がなく、すぐに始められるのは大きなメリットでした。
AssemblyScript公式ドキュメント:
実際に書いてみると、本当にTypeScriptと似ていて、型アノテーションが少し違う程度でした。
プロジェクト構成
今回のプロジェクト構成は以下のようになりました。
lab-202511-webassembly/
├── app/
│ ├── js-particles/page.tsx # JavaScript版ページ
│ ├── wasm-particles/page.tsx # WebAssembly版ページ
│ └── page.tsx # ホームページ
├── assembly/ # ← WebAssemblyのソース
│ ├── index.ts # AssemblyScriptで物理演算
│ └── tsconfig.json
├── lib/
│ └── particles.ts # JavaScript版物理演算
├── components/
│ └── FPSCounter.tsx
├── public/
│ └── particles.wasm # コンパイル済みWASM
├── asconfig.json # AssemblyScriptビルド設定
└── package.json
分かりやすく比較できるよう、JavaScript版とWebAssembly版を別々のページに分けました。共通のFPSカウンターコンポーネントで性能を測定できるようにしています。
ステップ1: AssemblyScriptのセットアップ
依存関係のインストール
まずはAssemblyScriptの開発環境を整えます。
{
"devDependencies": {
"assemblyscript": "^0.27.0"
},
"scripts": {
"asbuild:debug": "asc assembly/index.ts --target debug --outFile public/particles.wasm --bindings esm --exportRuntime",
"asbuild:release": "asc assembly/index.ts --target release --outFile public/particles.wasm --bindings esm --exportRuntime --optimize",
"asbuild": "npm run asbuild:release"
}
}
ここで重要なのは、--outFile public/particles.wasmでNext.jsの public/ディレクトリに出力することです。これによりブラウザからWASMファイルにアクセスできるようになります。
AssemblyScriptの設定
// assembly/tsconfig.json
{
"extends": "assemblyscript/std/assembly.json",
"include": ["./**/*.ts"]
}
// asconfig.json
{
"targets": {
"release": {
"outFile": "public/particles.wasm",
"optimizeLevel": 3,
"shrinkLevel": 0
}
},
"options": {
"bindings": "esm",
"exportRuntime": true
}
}
Next.jsの設定
Next.jsでWebAssemblyを使うには、webpack設定の追加が必要です。
// next.config.js
module.exports = {
webpack: (config) => {
config.experiments = {
...config.experiments,
asyncWebAssembly: true,
};
return config;
},
};
これだけで準備完了です。思っていたより簡単でした!
ステップ2: 物理演算ロジックの実装
JavaScript版の実装
まずは通常のTypeScriptで物理演算を実装しました。これが比較対象になります。
export class ParticleSystem {
particles: Particle[];
width: number;
height: number;
mouseX: number = 0;
mouseY: number = 0;
mouseRadius: number = 30;
update() {
for (let i = 0; i < this.particles.length; i++) {
const p = this.particles[i];
// マウスカーソルとの衝突判定
const dx = p.x - this.mouseX;
const dy = p.y - this.mouseY;
const distSq = dx * dx + dy * dy;
const minDist = this.mouseRadius + p.radius;
if (distSq < minDist * minDist && distSq > 0) {
const dist = Math.sqrt(distSq);
const nx = dx / dist;
const ny = dy / dist;
const overlap = minDist - dist;
p.x += nx * overlap;
p.y += ny * overlap;
const dotProduct = p.vx * nx + p.vy * ny;
p.vx -= 2 * dotProduct * nx;
p.vy -= 2 * dotProduct * ny;
}
// 位置更新
p.x += p.vx;
p.y += p.vy;
// 壁との衝突
if (p.x - p.radius < 0) {
p.x = p.radius;
p.vx *= -1;
}
// その他の境界処理...
// パーティクル間の衝突判定
for (let j = i + 1; j < this.particles.length; j++) {
// 衝突処理...
}
}
}
}
AssemblyScript版の実装
JavaScriptと違い、WebAssemblyではメモリを直接管理する必要があります。パーティクルデータを1つの連続したFloat64Arrayに格納することにしました。
// assembly/index.ts
// パーティクルのメモリレイアウト: [x, y, vx, vy, radius]
const PARTICLE_SIZE = 5;
let particleCount: i32 = 0;
let width: f64 = 800;
let height: f64 = 600;
let mouseX: f64 = 0;
let mouseY: f64 = 0;
const MOUSE_RADIUS: f64 = 30.0;
// メモリバッファを確保(最大20000パーティクル)
const MAX_PARTICLES = 20000;
const buffer = new Float64Array(MAX_PARTICLES * PARTICLE_SIZE);
export function init(count: i32, w: f64, h: f64): void {
particleCount = count;
width = w;
height = h;
for (let i: i32 = 0; i < count; i++) {
const offset = i * PARTICLE_SIZE;
buffer[offset] = Math.random() * width; // x
buffer[offset + 1] = Math.random() * height; // y
buffer[offset + 2] = (Math.random() - 0.5) * 4; // vx
buffer[offset + 3] = (Math.random() - 0.5) * 4; // vy
buffer[offset + 4] = 3.0; // radius
}
}
export function update(): void {
for (let i: i32 = 0; i < particleCount; i++) {
const offset = i * PARTICLE_SIZE;
let x = buffer[offset];
let y = buffer[offset + 1];
let vx = buffer[offset + 2];
let vy = buffer[offset + 3];
const radius = buffer[offset + 4];
// 物理演算処理(JavaScript版と同じロジック)
// ...
// 値を書き戻す
buffer[offset] = x;
buffer[offset + 1] = y;
buffer[offset + 2] = vx;
buffer[offset + 3] = vy;
}
}
export function getBufferPointer(): usize {
return buffer.dataStart;
}
JavaScript版との主な違いは以下です:
-
型アノテーション:
i32(32bit整数)、f64(64bit浮動小数点)の明示 - メモリ管理: オブジェクトの配列ではなく、連続したメモリ配列
-
データアクセス:
buffer[offset]でインデックス計算が必要
書いてみると、確かにTypeScriptと似ていますが、メモリを意識した実装が必要で、やや面倒でした。
ステップ3: Next.jsでWebAssemblyを読み込む
WebAssemblyのビルドと読み込み
npm run asbuild
でWASMファイルをビルドした後、React Componentで読み込みます。
'use client';
import { useEffect, useRef, useState } from 'react';
interface WasmModule {
init: (count: number, width: number, height: number) => void;
update: () => void;
updateMousePosition: (x: number, y: number) => void;
getBufferPointer: () => number;
getParticleCount: () => number;
memory: WebAssembly.Memory;
}
export default function WasmParticlesPage() {
const [isLoading, setIsLoading] = useState(true);
const wasmRef = useRef<WasmModule | null>(null);
useEffect(() => {
const init = async () => {
try {
// WASMファイルをフェッチ
const response = await fetch('/particles.wasm');
const buffer = await response.arrayBuffer();
// AssemblyScriptランタイムのインポート設定
const imports = {
env: {
abort: () => console.error('abort called'),
seed: () => Date.now(),
}
};
// WebAssemblyをインスタンス化
const { instance } = await WebAssembly.instantiate(buffer, imports);
const wasmModule = instance.exports as any as WasmModule;
wasmRef.current = wasmModule;
// 初期化
wasmModule.init(particleCount, canvas.width, canvas.height);
// メモリバッファを取得
const bufferPointer = wasmModule.getBufferPointer();
const particleBuffer = new Float64Array(
wasmModule.memory.buffer,
bufferPointer,
particleCount * 5
);
setIsLoading(false);
// アニメーションループ
const animate = () => {
// 物理演算(WASM側)
wasmModule.update();
// レンダリング(JavaScript側)
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (let i = 0; i < wasmModule.getParticleCount(); i++) {
const offset = i * 5;
const x = particleBuffer[offset];
const y = particleBuffer[offset + 1];
const radius = particleBuffer[offset + 4];
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fill();
}
requestAnimationFrame(animate);
};
animate();
} catch (error) {
console.error('WebAssemblyのロードに失敗:', error);
}
};
init();
}, []);
return (
<div>
<canvas ref={canvasRef} />
{isLoading && <div>WebAssembly読み込み中...</div>}
</div>
);
}
ここで重要なポイントは、責任分担を明確にしたことです:
- WASM側: 重い物理演算
- JavaScript側: Canvas描画などのUI処理
メモリは wasmModule.memory.bufferを介して共有します。
パフォーマンス結果と分析
さて、いよいよ気になる結果です。


実測データ
| パーティクル数 | JavaScript版 FPS | WebAssembly版 FPS |
|---|---|---|
| 1,000個 | 60 | 60 |
| 3,000個 | 60 | 9 |
| 5,000個 | 55 | 3 |
結果: WebAssemblyの方が圧倒的に遅い!😱
これは想定外でした。特に3,000個では60fpsから9fpsと、6分の1近くまで落ち込んでいます。
なぜWebAssemblyが遅いのか?
原因を詳しく分析してみました。
1. JavaScriptとWASM間のデータ転送コスト
// 毎フレーム実行される処理
wasmModule.update(); // ← WASMで計算
// メモリバッファから値を取得
for (let i = 0; i < currentCount; i++) {
const offset = i * 5;
const x = particleBuffer[offset]; // ← メモリアクセス
const y = particleBuffer[offset + 1]; // ← メモリアクセス
// Canvas描画はJavaScript側で実行
ctx.arc(x, y, radius, 0, Math.PI * 2);
}
毎フレーム、WASMメモリからJavaScriptへ大量のデータを転送する必要があります。パーティクル数が増えるほど、この転送コストが重くなります。
2. レンダリングのボトルネック
Canvas描画はJavaScript側で行うため、WASMで物理演算を高速化しても、レンダリング部分で結局JavaScriptの世界に戻ってしまいます。
3. JavaScriptエンジンの最適化
最近のJavaScriptエンジン(V8など)は非常に高度な最適化を行います。単純なループや算術演算はJITコンパイラが効率的に最適化するため、WebAssemblyとの性能差はそれほど大きくないことが多いです。
WebAssemblyが有利になるケース
今回は遅くなりましたが、以下のようなケースではWebAssemblyが有利になります:
✅ 計算量が多く、レンダリングが少ない場合
// 画像処理、暗号化、圧縮など
export function processImage(pixels: Uint8Array): Uint8Array {
// 数百万回のループ処理
for (let i = 0; i < pixels.length; i++) {
pixels[i] = complexCalculation(pixels[i]);
}
return pixels;
}
✅ JavaScript⇔WASM間のデータ転送が少ない場合
// 大量の計算を一度に実行し、結果だけを返す
const result = wasmModule.calculatePhysics(deltaTime);
学んだこと
1. WebAssemblyは銀の弾丸ではない
「重い処理 = WebAssembly」という単純な話ではないことを痛感しました。JavaScript⇔WASM間のデータ転送コストやレンダリングの責任分担を考慮しないと、逆に遅くなってしまいます。
2. AssemblyScriptは学習コストが低い
TypeScript開発者にとって、AssemblyScriptは本当に書きやすかったです。文法もほぼ同じで、型アノテーションが少し違う程度でした。
3. メモリ管理の重要性
WebAssemblyでは、メモリレイアウトを意識することが重要です。オブジェクトの配列ではなく、連続したメモリ配列を使う必要があります。
4. Next.jsとの統合は簡単
public/にWASMファイルを置いて fetch()でロードするだけ。next.config.jsで asyncWebAssembly: trueを設定すれば、思ったより簡単に導入できました。
改善案
今回のパフォーマンス問題を解決するには、以下のような改善が考えられます。
1. WebGLでレンダリング
Canvas 2DではなくWebGLを使い、GPU側でレンダリングすることで、JavaScript⇔WASM間のデータ転送を減らせます。
2. 全てをWASM側で完結
OffscreenCanvasを使い、レンダリングもWASM側で実行することで、JavaScript側とのやり取りを最小限に抑えます。
これらの改善により、WebAssemblyの真価を発揮できるかもしれません。
まとめ
WebAssemblyは確かに強力な技術ですが、適切なユースケースで使うことが重要だと学びました。今回の実装では期待したパフォーマンスは得られませんでしたが、WebAssemblyの導入方法と落とし穴を実際に体験できたのは貴重でした。
技術選定では、「新しくて速そう」というイメージだけでなく、実際のアーキテクチャやデータフローを考慮することの大切さを改めて感じています。
次回はWebGLと組み合わせて、より効果的にWebAssemblyを活用してみたいと思います👍
参考リンク
この記事が、Next.js開発者がWebAssemblyに挑戦する際の参考になれば嬉しいです。失敗事例も含めて共有することで、より良い技術選定ができるようになると信じています✨
株式会社StellarCreate(stellar-create.co.jp)のエンジニアブログです。 プロダクト指向のフルスタックエンジニアを目指す方募集中です! カジュアル面談で気軽に雑談しましょう!→ recruit.stellar-create.co.jp/
Discussion