注意点・パフォーマンス

本番投入前に必ず読み返すページ。Three.js は強力だが、「何もしないと自動で最適化されない」類の ライブラリなので、知っておくべき罠と最適化テクが多い。

メモリリーク(dispose の鉄則)

Three.js のGPU リソースは GC されない。明示的に dispose() を呼ぶこと。

dispose が必要なもの

シーン全体を破棄するヘルパー

function disposeNode(o) {
  if (o.geometry) o.geometry.dispose()
  if (o.material) {
    const mats = Array.isArray(o.material) ? o.material : [o.material]
    for (const m of mats) {
      for (const k of Object.keys(m)) {
        const v = m[k]
        if (v && v.isTexture) v.dispose()
      }
      m.dispose()
    }
  }
}

function disposeAll(root) {
  root.traverse(disposeNode)
}

// SPA でシーンを切り替えるとき
disposeAll(scene)
renderer.dispose()
renderer.domElement.remove()
「シーンから外す」≠「メモリ解放」

scene.remove(mesh) しただけでは GPU バッファは残る。
外すのとdispose するの両方が必要。順番は dispose → remove でも remove → dispose でも良い(参照が他にあると dispose しても完全には消えない)。

共有して使い回す

ジオメトリ・マテリアル・テクスチャは複数の Mesh で共有可能。同じ箱を 100 個出すなら、 ジオメトリとマテリアルは 1 つだけ作って共有する。

const geom = new THREE.BoxGeometry()
const mat  = new THREE.MeshStandardMaterial({ color: "#7dd3fc" })

for (let i = 0; i < 100; i++) {
  const m = new THREE.Mesh(geom, mat)   // 共有
  m.position.x = i * 0.5
  scene.add(m)
}

Instancing — 大量配置の決定版

100 個の箱を別々の Mesh にすると、ドローコール 100 回InstancedMesh なら1回

const N = 1000
const geom = new THREE.BoxGeometry()
const mat  = new THREE.MeshStandardMaterial()
const im   = new THREE.InstancedMesh(geom, mat, N)

const tmp = new THREE.Object3D()
for (let i = 0; i < N; i++) {
  tmp.position.set(
    (Math.random() - 0.5) * 50,
    0,
    (Math.random() - 0.5) * 50,
  )
  tmp.rotation.y = Math.random() * Math.PI * 2
  tmp.scale.setScalar(0.5 + Math.random())
  tmp.updateMatrix()
  im.setMatrixAt(i, tmp.matrix)
}
im.instanceMatrix.needsUpdate = true
scene.add(im)

個別の色を持たせたい時は im.setColorAt(i, color) + im.instanceColor.needsUpdate = true

BatchedMesh(新しめ)

異なるジオメトリを 1 つのドローコールで描く新機能(Three.js r163+)。 形が違うけど一括で描きたいシーンに有効。

LOD(Level of Detail)

距離に応じて簡略モデルへ切り替える。

const lod = new THREE.LOD()
lod.addLevel(highDetailMesh, 0)     // 0 m から
lod.addLevel(midDetailMesh, 10)     // 10 m から
lod.addLevel(lowDetailMesh, 30)     // 30 m から
scene.add(lod)
// カメラ距離は自動で判定される

Frustum culling

カメラに映っていないオブジェクトはレンダラが自動でスキップする。 ただしバウンディングが正しくないとスキップしない / 逆にスキップされて消えることがある。

レンダラ設定での節約

静的シーンは1回だけ render

変化がないシーンは毎フレーム描かない。必要な時だけ render() を呼ぶ。

function invalidate() {
  renderer.render(scene, camera)
}

// カメラを動かすたびに
controls.addEventListener("change", invalidate)
window.addEventListener("resize", () => {
  // ...
  invalidate()
})

// 初回
invalidate()

毎フレームのアロケーションを避ける

// BAD: 毎フレーム new
function update() {
  const v = new THREE.Vector3(...)
  mesh.position.copy(v)
}

// GOOD: 使い回す
const tmp = new THREE.Vector3()
function update() {
  tmp.set(...)
  mesh.position.copy(tmp)
}

SSR(Next.js / Astro / Nuxt)

Three.js は window / document を参照する。SSR でサーバ側でロードされるとクラッシュする。

カラースペース問題

Z-fighting(深度のチラつき)

2つの面がほぼ同じ深度にあると、フレームごとに前後が入れ替わってチラつく現象。

影のチラつき(shadow acne)

自分の影で表面が縞模様になる現象。

透明物の重なり

透明オブジェクトは奥から手前へ手動で並べるのが基本(深度書き込みしないため)。

WebGL コンテキストロスト

ブラウザの裏で別のWebGLコンテキストが立ち上がったり、GPU が忙しいとWebGL がロストする。 Three.js は復旧をサポートするが、自前リソースは作り直しが必要。

renderer.domElement.addEventListener("webglcontextlost", (e) => {
  e.preventDefault()
  // レンダーループを止めるなど
})
renderer.domElement.addEventListener("webglcontextrestored", () => {
  // リソース再作成
})

ブラウザサポート

計測ツール

バージョンアップへの備え

判断フロー

  1. 静的シーンなら → 1回 render で済ませる
  2. 同じ形が大量にあるなら → InstancedMesh / BatchedMesh
  3. カクつくなら → DPR を下げる、shadow map を縮める、antialias を切る
  4. 初回が重いなら → KTX2 / Draco / Meshopt 圧縮 + Loading 表示
  5. raycast が重いなら → 対象配列を絞る、layers で分ける、不要 mesh は raycast = () => {}
  6. SPA で遷移するなら → 必ず disposeAll + renderer.dispose
学び方の道筋

手を動かすなら Three.js Manual を1周、 深く学ぶなら Three.js Journey が定番。
サンプルは公式の examples が圧倒的に多く、 ソースコードもブラウザから読めるので、近い表現を探してコードを覗くのが最短。