[Babylon.js #03] WebGPUで MMD 1,000体を走らせる:大量描画と影の最適化戦略

はじめに

前回の記事で「1体の描画」に成功した後、次に挑んだのは「数の暴力」。WebGPUの真価を問うべく、1,000体のMMDモデルを同時描画し、60FPSを目指した最適化の記録です。

前回の記事:

スクリーンショット:

動画:

1. 1,000体へのスケールアップ:InstancedMeshの活用

通常のメッシュ複製(clone)では、頂点データそのものをコピーするため、1,000体分ものメモリと描画負荷に耐えられません。そこで、一つの頂点データを参照しつつ描画命令を統合する InstancedMesh を活用しました。

警告の嵐を止める「ジオメトリ・フィルタリング」

MMDモデルは、ボーン構造を司る「空の親メッシュ」と、実際に描画される「パーツメッシュ」が混在しています。これらを一括でインスタンス化しようとすると、Babylon.jsは「頂点がないものは複製できない」と警告を出し続け、パフォーマンスを著しく低下させます。

これを防ぐため、読み込み時に以下のフィルタリングを行いました。

// 頂点データ(Geometry)を持っている実体のあるメッシュだけを抽出
characterMeshes = mmdImportResult.meshes.filter(
    (m) => m instanceof Mesh && m.getTotalVertices() > 0
) as Mesh[];

足りない分だけを生成する動的インスタンス管理

UIのスライダーに合わせて、必要な数だけインスタンスを生成・管理するロジックです。

while (instanceGroups.length < count - 1) {
    const i = instanceGroups.length + 1; // 2体目以降のインデックス
    const group: any[] = [];

    // 分解されたパーツ(髪、体、服など)ごとにインスタンスを作成
    characterMeshes.forEach((m) => {
        const instance = m.createInstance(`${m.name}_inst_${i}`);
        instance.position.x = getX(i);
        instance.position.z = getZ(i);

        // 影の計算対象にも個別に登録
        if (shadowGenerator) shadowGenerator.addShadowCaster(instance);
        group.push(instance);
    });
    instanceGroups.push(group);
}

描画負荷のオーダー(記法)による考察

通常の描画(Draw Call)がキャラ数 に対して で増加するのに対し、インスタンス描画では原理的に に近づけることができます。今回の実装ではパーツごとにインスタンス化しているため、ドローコール数は「パーツ数 × マテリアル数」に抑えられ、1,000体並べてもCPUのメインスレッドが破綻しない仕組みになっています。


このセクションのポイント

  • 警告ログの排除: getTotalVertices() > 0 による事前フィルタリングが、FPS安定の隠れた功労者です。
  • インスタンス・グループ化: 1体のキャラを構成する複数パーツを group 配列として管理することで、setEnabled による一括表示・非表示を容易にしました。

2. 影の解像度:パフォーマンスの「魔法のスイッチ」

1,000体の軍勢を描画する際、最もGPUパワーを消費していたのは影のマップ(Shadow Map)の生成です。キャラクター1体ごとに数万の頂点がある場合、影を描画するためだけにその数倍の座標計算が必要になるため、単純計算では1,000体で数千万頂点の演算が毎フレーム発生することになります。

リアルタイム再構築ロジックの実装

Babylon.jsの ShadowGenerator は、インスタンス作成時に解像度を固定するため、UIから動的に変更するには「破棄」と「再構築」のプロセスが必要になります。

function updateShadowGenerator() {
  // 1. 古いジェネレーターを破棄してVRAMを解放
  if (shadowGenerator) {
    shadowGenerator.dispose();
  }

  // 2. 指定された解像度(128〜2048)で新規作成
  shadowGenerator = new ShadowGenerator(Number(settings.shadowRes), dirLight);

  // WebGPU環境で影を柔らかく見せるための設定
  shadowGenerator.useBlurExponentialShadowMap = true;
  shadowGenerator.enableSoftTransparentShadow = true;

  // 3. 全てのキャラクター(オリジナル+インスタンス)を再登録
  characterMeshes.forEach((m) => shadowGenerator.addShadowCaster(m));
  instanceGroups.forEach((group) => {
    group.forEach((inst) => shadowGenerator.addShadowCaster(inst));
  });
}

FPSを倍増させる「解像度のトレードオフ」

検証の結果、影の解像度はパフォーマンスに直結する「特効薬」であることが判明しました。

  • 1024px以上: 影の輪郭は非常に鮮明だが、50体を超えたあたりでFPSが30台へ急落する。
  • 512px: 品質と速度のバランスが良い。100体程度までは60FPSを維持可能。
  • 128px: 影はボヤけるが、描画負荷が半分以下になり、1,000体表示でも実用的なフレームレートを叩き出せる。

究極の最適化:更新頻度(refreshRate)の制限

解像度を下げるだけでなく、「影を計算する頻度を間引く」手法も併用しました。

// 影の更新を「毎フレーム」から「2フレームに1回」に落とす
if (shadowGenerator.getShadowMap()) {
  shadowGenerator.getShadowMap()!.refreshRate = 2;
}

これにより、1,000体のLainが激しく踊っていても、影の計算コストをさらに50%カットすることに成功しました。


このセクションのポイント

  • 動的な解像度切り替え: コンストラクタ固定の値をUIから変更可能にすることで、ユーザーが自身のPCスペックに合わせた最適解を見つけられるようにしました。
  • 計算コストの可視化: 解像度を128まで下げることで、WebGPUのポテンシャルを「数」の暴力へと振り向けられることを証明しました。

3. メモリ管理と「数字アトラス」への布石

1,000マスのグリッドに番号を振る際、単純に考えれば1,000枚のテクスチャが必要になります。しかし、この「1,000枚」という数字が、WebGPUの運用において致命的なメモリ問題を引き起こします。

256MBの壁:低レイヤー開発者の宿命

今回の検証において特に意識したのが、一部の環境で見られる 「256MBのメモリ制限」 です。

  • メモリ計算の現実: ピクセルのRGBAテクスチャは、1枚あたり約256KBを消費します。
  • 爆発する消費量: これを1,000枚生成すると となり、テクスチャだけで制限値をほぼ使い切ってしまいます。
  • 実測値の驚異: 実際に1,000体を表示した際のJSヒープは522MBに達しており、VRAM(ビデオメモリ)側ではさらに深刻な圧迫が発生していました。

最適化戦略:DynamicTextureの解像度調整

まずは「確実に動くこと」を優先し、テクスチャ1枚あたりの解像度を128pxに落とすことで、メモリ消費量を4分の1に削減しました。

// 1,000枚作るためにテクスチャ解像度を128に下げてメモリを節約
const texSize = 128;
const tex = new DynamicTexture(`tex_${i}`, texSize, scene);

const ctx = tex.getContext();
ctx.font = "bold 60px Arial"; // 小さなテクスチャに合わせてフォントサイズを調整
ctx.fillText((i + 1).toString(), texSize / 2, texSize / 2);
tex.update();

究極の解決策としての「数字アトラス」

1,000枚のマテリアルとテクスチャを個別に管理することは、描画命令(Draw Call)の増大も招きます。これを根本から解決するのが 「数字アトラス(Digit Atlas)」 方式です。

  • 概念: 「0〜9」の数字を描いた1枚の大きなテクスチャのみを生成し、各マスのUV座標をずらして表示します。
  • 圧倒的な節約: テクスチャメモリ消費を99.9%削減できるだけでなく、1,000枚のマテリアルを「たった1つ」に統合できるため、描画性能が飛躍的に向上します。
  • 実装への準備: 今回のコードには、アトラス作成用のロジック(createNumberAtlas)を既に組み込んでおり、次なる「数万体規模」への拡張への布石としています。

このセクションのポイント

  • メモリの可視化: 単純な複製が如何に早くVRAMを食いつぶすかを、1,000という具体的な数値で実証しました。
  • スケーラビリティの確保: 低解像度化による暫定対応と、アトラス化による抜本的解決の二段構えで、WebGPUのポテンシャルを最大限に引き出す設計を目指しています。

4. 広大なシーンを自在に操るカメラコントロール

1,000体のキャラクターを のグリッドに並べると、シーン全体のスケールは奥行き 600m 級の巨大な戦場へと変貌します。Babylon.js の標準的なカメラ設定では、この広大すぎる空間を移動するにはあまりにも「歩幅」が小さすぎました。

空間スケールに合わせた感度調整

WebGPU の検証において、自由な視点移動はデバッグと演出の両面で不可欠です。特に右クリックドラッグによる水平移動(パン)の感度調整が、巨大シーンの操作感を左右する決定打となりました。

// パン(右クリック移動)の感度設定
// 数値を下げるほど、マウスの動きに対してカメラが大きく移動する
camera.panningSensibility = 50;

// マウスホイールによるズームの精度
camera.wheelPrecision = 10;

// 1,000体の軍勢を俯瞰するため、ズームアウトの限界値を拡張
camera.upperRadiusLimit = 2000;

標準では 1000 に設定されている panningSensibility50 まで引き上げる(感度を20倍にする)ことで、先頭の1体から最後尾の1,000体目までを、一瞬のスワイプで行き来できる機動性を確保しました。


今回の検証結果

WebGPU 環境下における「数」と「質」の相関関係を、FPS(フレームレート)の観点から記録しました。

キャラクター数 影解像度 (Shadow Res) FPS (WebGPU)
1体 1024 60
100体 512 60
500体 256 55
1000体 128 30~40

※ 検証環境:ブラウザ VRAM 制限 256MB 相当の環境下

最適化の成果

検証の結果、影の解像度を適切に「捨てる」ことで、WebGPU の演算リソースをキャラクターの「数」に全振りできることが証明されました。1,000体表示時に FPS 60 を維持するには、さらなる「行列計算の凍結(Freeze)」や「Thin Instance」への移行が有効な次の一手となります。


まとめ
WebGPU は単なる「速い描画エンジン」ではありません。影の解像度管理や freezeWorldMatrix といった、描画情報の取捨選択を開発者が低レイヤーでコントロールすることで、ブラウザ上の限界を軽々と超えていける、極めて自由度の高いキャンバスなのです。