Three.jsが重いときのパフォーマンス最適化まとめ【原因の測り方から】

Three.jsで物量を増やしていくと、いつか必ず「重い」に突き当たります。このとき当てずっぽうに軽量化を試すと大抵ハズレを引くので、まず計測して、ボトルネックに合った対策を打つのが鉄則です。この記事は計測の方法と、効果の大きい対策を優先度順にまとめたハブ記事です。

まず計測: FPSと描画コール

FPSメーターはThree.js同梱のStatsを2行入れるだけです。

import Stats from 'three/addons/libs/stats.module.js';

const stats = new Stats();
document.body.appendChild(stats.dom);
// ループ内で stats.update();

もう1つ見るべきは renderer.info です。

console.log(renderer.info.render.calls);     // 描画コール数
console.log(renderer.info.render.triangles); // 三角形数
console.log(renderer.info.memory);           // geometries / textures数

重さの原因は大きく「描画コールが多い(CPU)」か「ピクセル処理が重い(GPU)」の2系統に分かれます。描画コールが数百〜数千あるなら前者、コール数は少ないのに重い(特に高解像度で悪化する)なら後者です。

対策1: 描画コールを減らす — InstancedMesh

Three.jsはメッシュ1つにつき描画コール1回が基本です。同じ形状を大量に並べるなら、InstancedMesh で1回にまとめられます。

デモで体感してください。10,000個のキューブを、InstancedMesh(描画コール1)と個別Mesh(描画コール10,000)で切り替えられます。左上がFPS、右上に描画コール数が出ます。

デモを別タブで開く

const mesh = new THREE.InstancedMesh(geometry, material, COUNT);
const matrix = new THREE.Matrix4();

for (let i = 0; i < COUNT; i++) {
  matrix.setPosition(x, y, z);
  mesh.setMatrixAt(i, matrix); // i番目の位置・回転・スケール
  mesh.setColorAt(i, color);   // 色も個別に持てる
}
mesh.instanceMatrix.needsUpdate = true;
scene.add(mesh);

草・木・瓦礫・弾丸など「同じ形の繰り返し」はすべてこれの出番です。形が違うオブジェクトの場合は、BufferGeometryUtils.mergeGeometries() で1つのジオメトリに結合してコールをまとめる手もあります(動かせなくなるので静的な背景向け)。

対策2: pixelRatioに上限を付ける — GPU系の特効薬

高DPIディスプレイ(スマホはほぼ全部)では、devicePixelRatio が3のとき描画ピクセル数が9倍になります。人の目には2で十分なので、上限を付けます。

renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));

たった1行ですが、スマホでの体感に一番効くことが多い対策です。「PCでは快適なのにスマホで重い」ならまずこれ。

対策3: 影とライトを疑う

影は見た目以上に高コストです。シーン全体を影用にもう1回描画しているようなものなので:

  • 影が本当に必要なライトだけ castShadow = true にする(全ライト・全オブジェクトでONにしない)
  • light.shadow.mapSize はデフォルト(512)から必要なぶんだけ上げる。4096などは覿面に重い
  • 動かない背景の影なら、テクスチャに焼いた偽影(丸い半透明画像)で代用する

ライトの数も直接コストです。特にポイントライトとスポットライトを大量に置くと急激に重くなります。環境光+平行光源1本を基本に、ライトの使い分けの記事の構成から必要最小限で足してください。

対策4: モデルとテクスチャを軽くする

  • ジオメトリ: ポリゴン数を減らす(Blenderのデシメート)、Draco圧縮で転送量を削る。遠くのオブジェクトはTHREE.LODで低ポリ版に切り替え
  • テクスチャ: 解像度を必要最小限に(4096は大抵過剰)。サイズは2の累乗で。大量にあるならKTX2(GPU圧縮テクスチャ)への変換も検討
  • Blender側の出し方エクスポート設定の記事にまとめてあります

対策5: 生成と破棄のコストを消す

  • ループ内で new しないnew THREE.Vector3() を毎フレーム作るとGCが走ってカクつきの原因になります。使い回し用の変数を外に持つ
  • マテリアルとジオメトリを共有する — 同じ見た目のオブジェクトごとに new MeshStandardMaterial() を作らず、1つを使い回す
  • 消したら dispose()scene.remove() はGPUメモリを解放しません。geometry.dispose() / material.dispose() / texture.dispose() を呼ばないと、長時間動かすページでメモリが積み上がっていきます

対策6: 更新処理そのものを減らす

  • 画面に映らないもの(遠くの敵のAI、見えないアニメーション)は更新をスキップする
  • Raycasterの判定はイベント時だけ・対象を絞って
  • 物理エンジンは寝ている(動いていない)ボディの自動スリープに任せ、不要なボディは消す
  • マルチプレイヤー同期の送信は間引く(20回/秒で十分)

優先順位のガイド

経験上、効果が出やすい順はこうです。

  1. pixelRatio上限(1行で済む。スマホに覿面)
  2. 描画コール削減(InstancedMesh / ジオメトリ結合)
  3. 影の削減・縮小
  4. テクスチャとモデルの軽量化
  5. その他の細かいチューニング

「とりあえず全部やる」ではなく、Statsと renderer.info を見ながら1つずつ試して、効いたかどうかを確認しながら進めてください。計測なしの最適化は、だいたい徒労に終わります(何度もやりました)。