Three.js 實務效能最佳化 – 使用 Instancing
簡介
在 3D 網頁開發中,渲染大量相同模型(例如樹木、建築、粒子)是最常見的效能瓶頸。每個模型若都建立獨立的 Mesh,CPU 必須為每個物件分配一次 Geometry、Material,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 的步驟
- 建立 Geometry 與 Material(與普通 Mesh 相同)。
- 建立 InstancedMesh:
new THREE.InstancedMesh( geometry, material, count )。 - 設定每個實例的矩陣:使用
setMatrixAt( index, matrix )。 - 若需要動態更新,呼叫
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 不會收到變更。 |
每次呼叫 setMatrixAt 或 setColorAt 後,務必 instancedMesh.instanceMatrix.needsUpdate = true。 |
| 過多的 InstancedBufferAttribute | 每個額外屬性都會佔用顯示卡記憶體,過多會導致記憶體瓶頸。 | 僅保留必要屬性,使用 float、vec2、vec3 合理壓縮資料。 |
| 實例數量過大導致 CPU 迴圈卡頓 | 初始化時若一次迴圈設定數十萬個實例,CPU 仍會花費時間。 | 使用 分批 或 Web Worker 產生矩陣資料,或在需要時逐步加入實例(例如滾動載入)。 |
| 視野外的實例仍被渲染 | InstancedMesh 預設不會自動剔除視野外的實例,會浪費 GPU 資源。 | 可透過 Frustum Culling 搭配自訂 instanceId 或在 shader 中使用 gl_Position 前的裁切判斷。 |
最佳實踐
- 共用 Geometry & Material:盡量讓所有實例共享同一套資源,避免重複載入。
- 使用
Object3D暫存:在設定矩陣時,利用Object3D的position/rotation/scale直接計算,程式碼更易讀且效能相同。 - 批次更新:若只改變少部份實例,僅更新該區段的矩陣或屬性,減少
needsUpdate的頻率。 - 結合 LOD(Level of Detail):遠距離使用低多邊形的 InstancedMesh,近距離再切換較高細節模型。
- 測試與 Profiling:使用 Chrome DevTools 的 Performance 與 WebGL 面板,觀察 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 網頁作品,讓渲染效能提升到全新層次吧! 🚀