☠️

Next.jsでWebAssembly(AssemblyScript)を導入してパーティクルシミュレーションを実装したら遅くなった話

に公開

本記事のサマリ

Next.jsでパーティクルシミュレーションを作る際、「重い物理演算はWebAssemblyで高速化しよう」と考えAssemblyScriptを導入してみました。しかし結果は予想に反して、JavaScript版よりも大幅に遅くなってしまいました。この記事では、なぜWebAssemblyが期待通りに動作しなかったのか、その原因を整理してみました。

今回のコードは下記です。

https://github.com/toto-inu/lab-202511-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に近いパフォーマンスでネイティブコードを実行できる」と説明されています。

https://developer.mozilla.org/ja/docs/WebAssembly

計算集約的な処理では特に効果が期待できるということで、「実際にどれくらい速くなるのか?」を自分の手で確かめてみたくなったのです。

AssemblyScriptを選んだ理由

WebAssemblyを書く方法はいくつかありますが、今回はAssemblyScriptを選択しました。選択肢を比較してみると以下のような感じです。

言語/ツール メリット デメリット
C/C++ (Emscripten) 最高レベルの性能、成熟したツール 学習コスト高、ビルド設定が複雑
Rust メモリ安全性、優れた型システム 学習コスト高、コンパイルが重い
AssemblyScript TypeScript風の構文、導入が簡単 最適化はやや劣る

Next.js開発者にとってAssemblyScriptの最大の利点は、TypeScriptとほぼ同じ構文で書けることです。新しい言語を学ぶ必要がなく、すぐに始められるのは大きなメリットでした。

AssemblyScript公式ドキュメント:
https://www.assemblyscript.org/

実際に書いてみると、本当に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版との主な違いは以下です:

  1. 型アノテーション: i32 (32bit整数)、f64 (64bit浮動小数点)の明示
  2. メモリ管理: オブジェクトの配列ではなく、連続したメモリ配列
  3. データアクセス: 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を介して共有します。

パフォーマンス結果と分析

さて、いよいよ気になる結果です。

20251119233704.png

20251119233353.png

実測データ

パーティクル数 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.jsasyncWebAssembly: trueを設定すれば、思ったより簡単に導入できました。

改善案

今回のパフォーマンス問題を解決するには、以下のような改善が考えられます。

1. WebGLでレンダリング

Canvas 2DではなくWebGLを使い、GPU側でレンダリングすることで、JavaScript⇔WASM間のデータ転送を減らせます。

2. 全てをWASM側で完結

OffscreenCanvasを使い、レンダリングもWASM側で実行することで、JavaScript側とのやり取りを最小限に抑えます。

これらの改善により、WebAssemblyの真価を発揮できるかもしれません。

まとめ

WebAssemblyは確かに強力な技術ですが、適切なユースケースで使うことが重要だと学びました。今回の実装では期待したパフォーマンスは得られませんでしたが、WebAssemblyの導入方法と落とし穴を実際に体験できたのは貴重でした。

技術選定では、「新しくて速そう」というイメージだけでなく、実際のアーキテクチャやデータフローを考慮することの大切さを改めて感じています。

次回はWebGLと組み合わせて、より効果的にWebAssemblyを活用してみたいと思います👍

参考リンク

https://www.assemblyscript.org/

https://developer.mozilla.org/ja/docs/WebAssembly

https://nextjs.org/docs/app/api-reference/next-config-js/webpack

この記事が、Next.js開発者がWebAssemblyに挑戦する際の参考になれば嬉しいです。失敗事例も含めて共有することで、より良い技術選定ができるようになると信じています✨

株式会社StellarCreate | Tech blog📚

Discussion