はじめに
前回の記事で「1体の描画」に成功した後、次に挑んだのは「数の暴力」。WebGPUの真価を問うべく、1,000体のMMDモデルを同時描画し、60FPSを目指した最適化の記録です。
前回の記事:
[Babylon.js #02] 自作MMDモデルをWebGPUでアニメーション (pmx / vmd converter)
Babylon.jsとWebGPUでMMDモデルを表示する際に直面するシェーダーエラー、Tポーズで固まる問題、そしてアニメーションが途中で止まる「15秒の壁」。これらをbpmxLoaderとフレーム数スキャンロジックで解決し、影とBloomで仕上げるまでの全記 …
https://humanxai.info/posts/babylonjs-02-mmd-webgpu-bpmx/スクリーンショット:
動画:
[WebGPU +Babylon.js] ブラウザでMMD 1,000体を同時に走らせるデモ
Babylon.js と WebGPU を使い、1,000体のMMDモデル(lain)を同時描画する検証デモです。1,000枚のインスタンス描画と影の解像度最適化により、ブラウザ上での大量描画の限界に挑戦。▼ブログhttps://humanxai.info/posts/babylonjs-03-webgpu-10...
https://www.youtube.com/shorts/p-d6HMCuA4o1. 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 に設定されている panningSensibility を 50 まで引き上げる(感度を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といった、描画情報の取捨選択を開発者が低レイヤーでコントロールすることで、ブラウザ上の限界を軽々と超えていける、極めて自由度の高いキャンバスなのです。
💬 コメント