本文 AI 產出,尚未審核

Three.js 實務效能最佳化 – 使用 Instancing

簡介

在 3D 網頁開發中,渲染大量相同模型(例如樹木、建築、粒子)是最常見的效能瓶頸。每個模型若都建立獨立的 Mesh,CPU 必須為每個物件分配一次 GeometryMaterial,GPU 也會收到成千上萬筆 draw call,導致畫面卡頓、幀率下降。

Instancing(實例化)正是為了解決這個問題而設計的技術。它允許我們在 一次 draw call 中渲染上千甚至上萬個相同的物件,僅透過少量的額外屬性(如位置、旋轉、縮放)來區分每個實例。對於需要大量重複物件的場景,Instancing 能將 CPU 與 GPU 的負擔降低至原來的 1% 以下,是效能優化的關鍵手段。

本篇文章將從概念、實作、常見陷阱與最佳實踐,逐步帶領讀者在 Three.js 中掌握 Instancing,並提供實務範例與應用情境,讓你能在專案中即時提升渲染效能。


核心概念

1. InstancedMesh 基本原理

Three.js 在 r112 之後提供了 THREE.InstancedMesh 類別,核心概念如下:

項目 說明
Geometry 所有實例共用同一個 BufferGeometry(如 Box、Sphere)。
Material 所有實例共用同一個 Material,若需要不同顏色可使用 onBeforeRender 或自訂 shader。
instanceCount 要渲染的實例數量,最多 2⁴⁰⁶‑1(理論上)。
instanceMatrix 每個實例的變換矩陣(位置、旋轉、縮放),儲存在 InstancedMesh.instanceMatrix 中。

InstancedMesh 只會產生 一次 draw call,GPU 會在 shader 中根據 instanceMatrix 逐一套用變換,達到批次渲染的效果。

2. 建立 InstancedMesh 的步驟

  1. 建立 Geometry 與 Material(與普通 Mesh 相同)。
  2. 建立 InstancedMeshnew THREE.InstancedMesh( geometry, material, count )
  3. 設定每個實例的矩陣:使用 setMatrixAt( index, matrix )
  4. 若需要動態更新,呼叫 instanceMatrix.needsUpdate = true

3. 何時使用 Instancing

  • 大量相同模型:樹林、草地、城市建築、粒子系統。
  • 需要頻繁變更位置:例如隨機散佈的環境資產或動態生成的敵人。
  • 對材質需求一致:所有實例共享同一套 shader,若需要個別顏色可透過額外的 InstancedBufferAttribute 送入自訂屬性。

程式碼範例

下面提供 五個實用範例,逐步展示 Instancing 的不同應用方式。每段程式碼均以完整註解說明,方便直接貼上測試。

範例 1️⃣ 基本 InstancedMesh(渲染 1,000 個盒子)

// 1. 基本設定
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 0.1, 1000);
camera.position.set(0, 20, 50);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(innerWidth, innerHeight);
document.body.appendChild(renderer.domElement);

// 2. 共同的 Geometry 與 Material
const geometry = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshStandardMaterial({ color: 0x156289, metalness: 0.5, roughness: 0.5 });

// 3. 建立 InstancedMesh,設定要渲染的實例數量
const count = 1000;
const instancedMesh = new THREE.InstancedMesh(geometry, material, count);
scene.add(instancedMesh);

// 4. 為每個實例設定隨機位置、旋轉與縮放
const dummy = new THREE.Object3D(); // 暫存用的 Object3D,方便計算矩陣
for (let i = 0; i < count; i++) {
  dummy.position.set(
    THREE.MathUtils.randFloatSpread(100), // X
    THREE.MathUtils.randFloatSpread(20),  // Y
    THREE.MathUtils.randFloatSpread(100)  // Z
  );
  dummy.rotation.set(
    THREE.MathUtils.randFloat(0, Math.PI),
    THREE.MathUtils.randFloat(0, Math.PI),
    THREE.MathUtils.randFloat(0, Math.PI)
  );
  dummy.scale.setScalar(THREE.MathUtils.randFloat(0.5, 2));
  dummy.updateMatrix();               // 更新矩陣
  instancedMesh.setMatrixAt(i, dummy.matrix);
}

// 5. 必要的光源
const light = new THREE.DirectionalLight(0xffffff, 1);
light.position.set(10, 20, 10);
scene.add(light);

// 6. 渲染循環
function animate() {
  requestAnimationFrame(animate);
  instancedMesh.rotation.y += 0.001; // 整體旋轉示範
  renderer.render(scene, camera);
}
animate();

重點:只需一次 new THREE.InstancedMesh,就能一次渲染千個盒子,CPU 與 GPU 負擔相較於 1,000 個獨立 Mesh 大幅降低。


範例 2️⃣ 使用 InstancedBufferAttribute 傳遞顏色

const count = 500;
const geometry = new THREE.SphereGeometry(0.5, 16, 16);
const material = new THREE.MeshBasicMaterial({ vertexColors: true });

const mesh = new THREE.InstancedMesh(geometry, material, count);

// 建立顏色緩衝 (RGB)
const colors = new Float32Array(count * 3);
for (let i = 0; i < count; i++) {
  colors[i * 3 + 0] = Math.random(); // R
  colors[i * 3 + 1] = Math.random(); // G
  colors[i * 3 + 2] = Math.random(); // B
}
mesh.instanceColor = new THREE.InstancedBufferAttribute(colors, 3);

// 設定位置矩陣(與範例 1 相同)
const dummy = new THREE.Object3D();
for (let i = 0; i < count; i++) {
  dummy.position.set(
    THREE.MathUtils.randFloatSpread(50),
    THREE.MathUtils.randFloatSpread(10),
    THREE.MathUtils.randFloatSpread(50)
  );
  dummy.updateMatrix();
  mesh.setMatrixAt(i, dummy.matrix);
}
scene.add(mesh);

說明instanceColor 讓每個實例可以擁有獨立的顏色,而不必額外建立多個材質。只要在 Material 設定 vertexColors: true,shader 會自動讀取此屬性。


範例 3️⃣ 動態更新(移動的粒子系統)

const particleCount = 2000;
const geometry = new THREE.SphereGeometry(0.1, 8, 8);
const material = new THREE.MeshBasicMaterial({ color: 0xffffff });

const particles = new THREE.InstancedMesh(geometry, material, particleCount);
scene.add(particles);

const dummy = new THREE.Object3D();
const speeds = [];

// 初始化位置與速度
for (let i = 0; i < particleCount; i++) {
  dummy.position.set(
    THREE.MathUtils.randFloatSpread(100),
    THREE.MathUtils.randFloatSpread(100),
    THREE.MathUtils.randFloatSpread(100)
  );
  dummy.updateMatrix();
  particles.setMatrixAt(i, dummy.matrix);
  speeds.push(THREE.MathUtils.randFloat(0.02, 0.1)); // 每顆粒子不同的移動速度
}
particles.instanceMatrix.needsUpdate = true;

// 動態更新函式
function updateParticles() {
  for (let i = 0; i < particleCount; i++) {
    particles.getMatrixAt(i, dummy.matrix);
    dummy.position.setFromMatrixPosition(dummy.matrix);
    dummy.position.y -= speeds[i]; // 向下移動

    // 若超出範圍則重置到上方
    if (dummy.position.y < -50) dummy.position.y = 50;
    dummy.updateMatrix();
    particles.setMatrixAt(i, dummy.matrix);
  }
  particles.instanceMatrix.needsUpdate = true;
}

// 主循環
function animate() {
  requestAnimationFrame(animate);
  updateParticles();
  renderer.render(scene, camera);
}
animate();

技巧instanceMatrix.needsUpdate = true 必須在每次修改後設為 true,才能讓 GPU 取得最新的矩陣資料。對於大量動態實例,建議使用 GPU Instancing(WebGL2)或 Shader Texture 以減少 CPU 傳輸。


範例 4️⃣ 結合自訂 Shader(控制每個實例的透明度)

// 1. 自訂 ShaderMaterial
const vertexShader = `
  attribute vec3 position;
  attribute mat4 instanceMatrix;
  attribute float instanceAlpha; // 新增的屬性
  varying float vAlpha;
  void main() {
    vAlpha = instanceAlpha;
    gl_Position = projectionMatrix * modelViewMatrix * instanceMatrix * vec4(position, 1.0);
  }
`;

const fragmentShader = `
  varying float vAlpha;
  void main() {
    gl_FragColor = vec4(0.2, 0.7, 1.0, vAlpha);
  }
`;

const material = new THREE.ShaderMaterial({
  vertexShader,
  fragmentShader,
  transparent: true
});

const count = 300;
const geometry = new THREE.ConeGeometry(0.5, 2, 8);
const mesh = new THREE.InstancedMesh(geometry, material, count);

// 2. 建立透明度緩衝
const alphas = new Float32Array(count);
for (let i = 0; i < count; i++) {
  alphas[i] = THREE.MathUtils.randFloat(0.2, 1.0);
}
mesh.instanceAlpha = new THREE.InstancedBufferAttribute(alphas, 1);

// 3. 設定位置矩陣
const dummy = new THREE.Object3D();
for (let i = 0; i < count; i++) {
  dummy.position.set(
    THREE.MathUtils.randFloatSpread(30),
    THREE.MathUtils.randFloatSpread(30),
    THREE.MathUtils.randFloatSpread(30)
  );
  dummy.updateMatrix();
  mesh.setMatrixAt(i, dummy.matrix);
}
scene.add(mesh);

關鍵:自訂 InstancedBufferAttribute(如 instanceAlpha)可以把任意資料傳給 shader,讓每個實例擁有獨特的屬性(透明度、顏色、紋理索引等),而不必額外建立多個材質。


範例 5️⃣ 大規模城市模型(10,000 棟建築)

const buildingCount = 10000;
const boxGeom = new THREE.BoxGeometry(1, 1, 1);
const buildingMat = new THREE.MeshLambertMaterial({ color: 0x8d8d8d });

const city = new THREE.InstancedMesh(boxGeom, buildingMat, buildingCount);
scene.add(city);

const dummy = new THREE.Object3D();
for (let i = 0; i < buildingCount; i++) {
  // 隨機位置
  dummy.position.set(
    (i % 100) * 5 - 250,               // X:排成 100 行
    0,
    Math.floor(i / 100) * 5 - 250      // Z:每 100 個換一列
  );

  // 隨機高度 (Y 軸縮放)
  const height = THREE.MathUtils.randFloat(2, 20);
  dummy.scale.set(4, height, 4);
  dummy.updateMatrix();
  city.setMatrixAt(i, dummy.matrix);
}
city.instanceMatrix.needsUpdate = true;

說明:即使一次渲染 10,000 棟建築,仍只產生 一個 draw call,對於城市或大規模場景的效能提升尤為顯著。


常見陷阱與最佳實踐

陷阱 說明 解決方案
材質不支援 Instancing 某些內建材質(如 MeshBasicMaterial)在舊版瀏覽器或自訂 shader 未加入 instanceMatrix 會失效。 使用 InstancedMesh 前,確認材質支援 instanceMatrix,或自行在 shader 中宣告 attribute mat4 instanceMatrix;
矩陣更新忘記設 needsUpdate 修改 instanceMatrix 後若未設定 needsUpdate = true,GPU 不會收到變更。 每次呼叫 setMatrixAtsetColorAt 後,務必 instancedMesh.instanceMatrix.needsUpdate = true
過多的 InstancedBufferAttribute 每個額外屬性都會佔用顯示卡記憶體,過多會導致記憶體瓶頸。 僅保留必要屬性,使用 floatvec2vec3 合理壓縮資料。
實例數量過大導致 CPU 迴圈卡頓 初始化時若一次迴圈設定數十萬個實例,CPU 仍會花費時間。 使用 分批Web Worker 產生矩陣資料,或在需要時逐步加入實例(例如滾動載入)。
視野外的實例仍被渲染 InstancedMesh 預設不會自動剔除視野外的實例,會浪費 GPU 資源。 可透過 Frustum Culling 搭配自訂 instanceId 或在 shader 中使用 gl_Position 前的裁切判斷。

最佳實踐

  1. 共用 Geometry & Material:盡量讓所有實例共享同一套資源,避免重複載入。
  2. 使用 Object3D 暫存:在設定矩陣時,利用 Object3Dposition/rotation/scale 直接計算,程式碼更易讀且效能相同。
  3. 批次更新:若只改變少部份實例,僅更新該區段的矩陣或屬性,減少 needsUpdate 的頻率。
  4. 結合 LOD(Level of Detail):遠距離使用低多邊形的 InstancedMesh,近距離再切換較高細節模型。
  5. 測試與 Profiling:使用 Chrome DevTools 的 PerformanceWebGL 面板,觀察 draw call 數量與 GPU 記憶體使用情況,確保 Instancing 真正減少了渲染負擔。

實際應用場景

場景 為何適合 Instancing
森林或草原 成千上萬的樹木/草叢模型,使用 InstancedMesh 可一次渲染,帧率提升 5~10 倍。
城市建模 建築物、道路燈柱等重複結構,Instancing 減少 draw call,適合大型開放世界。
粒子特效 爆炸、煙霧、星塵等需要大量小型幾何體的特效,InstancedMesh 搭配自訂 shader 可實現高效能粒子系統。
多人遊戲角色 同一種角色模型的多個玩家(如同一職業),使用 Instancing 渲染可降低伺服器端的傳輸與客戶端的渲染負擔。
資料視覺化 大量點雲或柱狀圖(如金融、地理資訊),Instancing 可快速呈現上萬筆資料。

案例:在一個 WebGL 版的《Minecraft》原型中,開發者使用 InstancedMesh 渲染地形方塊(每個區塊 16×16×16 個方塊),即使在低階筆電上也能維持 60 FPS,證明 Instancing 對於格子式場景的效能提升是決定性的。


總結

  • Instancing 是 Three.js 中最直接、最有效的效能優化手段之一,能讓大量相同模型在 單一 draw call 完成渲染。
  • 只要確保 Geometry、Material 共享,正確設定 instanceMatrix(與必要的自訂屬性),就能在不改變視覺效果的前提下,大幅降低 CPU 與 GPU 的負擔。
  • 在實作時要留意 材質支援、矩陣更新、屬性記憶體 等常見陷阱,並結合 LOD、分批載入 等策略,才能在大型或動態場景中發揮最大效能。

透過本文提供的概念說明與五個實作範例,你已經具備在 任何 Three.js 專案 中使用 Instancing 的能力。現在就把這些技巧應用於你的下一個 3D 網頁作品,讓渲染效能提升到全新層次吧! 🚀