本文 AI 產出,尚未審核

Three.js 效能最佳化 ── 降低 Draw Call


簡介

在 WebGL 與 Three.js 中,Draw Call(繪製指令)是將一段已備妥的幾何資料送到 GPU 進行渲染的最小單位。每一次 renderer.render() 內部都會產生多筆 Draw Call,GPU 必須切換狀態、綁定緩衝區、執行著色器,這些切換成本遠高於純粹的頂點計算。

當場景中有上千甚至上萬個物件時,若每個物件都以獨立的 Mesh 方式呈現,Draw Call 數量會急速飆升,導致 幀率下降、CPU 負載過高,最終讓使用者體驗變差。因此,學會有效降低 Draw Call,是在 Three.js 中實作大型或高互動性專案的關鍵技巧。

本篇文章將從概念說明、實作範例、常見陷阱與最佳實踐,帶你一步步掌握如何在 Three.js 中減少 Draw Call,讓你的 3D 網頁應用跑得更順暢。


核心概念

1. 為什麼 Draw Call 會成為瓶頸?

項目 說明
CPU → GPU 交互 每一次 Draw Call 都需要 CPU 向 GPU 發送指令,CPU 必須等候 GPU 完成前一次的工作,形成瓶頸。
狀態切換成本 切換材質、緩衝區、渲染目標等,都會產生額外開銷。
批次 (Batch) 效益 把多個物件合併成一次 Draw Call 能大幅降低指令數,提升效能。

小技巧:在開發階段,使用 renderer.info.render.calls 觀察實際的 Draw Call 數量,作為優化的基準。

2. 合併幾何體 (Geometry Merging)

將多個靜態 Mesh 的 BufferGeometry 合併成一個大幾何體,讓 GPU 只需要一次繪製指令即可渲染全部。Three.js 提供 BufferGeometryUtils.mergeBufferGeometries() 方便執行此操作。

範例 1:合併多個立方體

import * as THREE from 'three';
import { BufferGeometryUtils } from 'three/examples/jsm/utils/BufferGeometryUtils.js';

// 建立 100 個隨機位置的立方體
const geometries = [];
for (let i = 0; i < 100; i++) {
  const box = new THREE.BoxGeometry(1, 1, 1);
  box.translate(
    Math.random() * 20 - 10,
    Math.random() * 20 - 10,
    Math.random() * 20 - 10
  );
  geometries.push(box);
}

// 合併所有幾何體
const mergedGeometry = BufferGeometryUtils.mergeBufferGeometries(geometries, false);

// 使用單一材質
const material = new THREE.MeshStandardMaterial({ color: 0x6699ff });
const mergedMesh = new THREE.Mesh(mergedGeometry, material);
scene.add(mergedMesh);

重點:此方法適合靜態且不需要個別控制的物件;若需要單獨變形或顯隱,就不適合。

3. 使用 InstancedMesh (GPU Instancing)

InstancedMesh 允許在同一個 Draw Call 中渲染上千個相同幾何體,只需為每個實例提供變換矩陣與可選的自訂屬性。這是目前最有效的降低 Draw Call 的方式之一。

範例 2:渲染 10,000 棵樹(同一模型)

const treeGeometry = new THREE.ConeGeometry(0.5, 2, 8);
const treeMaterial = new THREE.MeshStandardMaterial({ color: 0x228822 });

const count = 10000;
const trees = new THREE.InstancedMesh(treeGeometry, treeMaterial, count);
scene.add(trees);

const dummy = new THREE.Object3D();
for (let i = 0; i < count; i++) {
  dummy.position.set(
    Math.random() * 200 - 100,
    0,
    Math.random() * 200 - 100
  );
  dummy.scale.setScalar(Math.random() * 0.5 + 0.5);
  dummy.updateMatrix();
  trees.setMatrixAt(i, dummy.matrix);
}

// 若需要每棵樹顏色不同,可使用 InstancedBufferAttribute
const colors = new Float32Array(count * 3);
for (let i = 0; i < count; i++) {
  colors[i * 3 + 0] = Math.random() * 0.5 + 0.5; // R
  colors[i * 3 + 1] = Math.random() * 0.8 + 0.2; // G
  colors[i * 3 + 2] = Math.random() * 0.5;       // B
}
treeGeometry.setAttribute('color', new THREE.InstancedBufferAttribute(colors, 3));
treeMaterial.vertexColors = true;

小提醒InstancedMesh 只能共用同一個材質與幾何體;若需要不同材質的變體,請考慮 分組 渲染或 多個 InstancedMesh

4. 文字圖集 (Texture Atlas)

多個使用不同貼圖的 Mesh 會導致每張貼圖產生一次 Draw Call。將多張小圖合併成一張大圖(Atlas),再透過 UV 偏移讓每個 Mesh 只使用同一張貼圖,即可減少 Draw Call。

範例 3:使用單一貼圖渲染多個圖示

// 假設已經有一張 1024x1024 的圖集 atlas.png
const atlasTexture = new THREE.TextureLoader().load('atlas.png');
atlasTexture.minFilter = THREE.LinearMipMapLinearFilter;

// 建立一個平面幾何體,之後會改變 UV
const planeGeo = new THREE.PlaneGeometry(1, 1);
const material = new THREE.MeshBasicMaterial({ map: atlasTexture, transparent: true });

function createIcon(u, v, uSize, vSize) {
  const mesh = new THREE.Mesh(planeGeo, material);
  // 調整 UV
  const uv = planeGeo.attributes.uv;
  const uvArray = uv.array;
  // 0~1 的 UV 需要根據圖集位置做偏移
  for (let i = 0; i < uvArray.length; i += 2) {
    uvArray[i]   = u + uvArray[i]   * uSize; // U
    uvArray[i+1] = v + uvArray[i+1] * vSize; // V
  }
  uv.needsUpdate = true;
  return mesh;
}

// 產生三個圖示,分別位於圖集的左上、右上、左下
scene.add(createIcon(0, 0.5, 0.5, 0.5)); // 左上
scene.add(createIcon(0.5, 0.5, 0.5, 0.5)); // 右上
scene.add(createIcon(0, 0, 0.5, 0.5)); // 左下

注意:圖集尺寸過大會影響記憶體與載入時間,適度分割(例如 2~4 張 2048×2048)往往比單一巨圖更實際。

5. 合併多材質 (Groups)

若一個 Mesh 使用多個材質(例如模型的不同部位),Three.js 會在渲染時把每個材質視為一次 Draw Call。將相同材質的子幾何體合併成 groups,確保每個 group 只對應一個材質,可減少冗餘的 Draw Call。

範例 4:模型分組與單材質化

// 假設有一個載入的 GLTF 模型
loader.load('model.glb', (gltf) => {
  const model = gltf.scene;
  const sharedMat = new THREE.MeshStandardMaterial({ color: 0xffffff });

  model.traverse((child) => {
    if (child.isMesh) {
      // 取出原本的 BufferGeometry
      const geom = child.geometry;
      // 重新設定單一材質
      child.material = sharedMat;
      // 若有多個子幾何體,使用 groups 合併
      // 這裡示範簡單的合併:把所有子 Mesh 的幾何體合併成一個
    }
  });

  // 合併所有幾何體(靜態模型)
  const geometries = model.children.map(m => m.geometry);
  const merged = BufferGeometryUtils.mergeBufferGeometries(geometries, false);
  const mergedMesh = new THREE.Mesh(merged, sharedMat);
  scene.add(mergedMesh);
});

常見陷阱與最佳實踐

陷阱 說明 解法
過度合併導致失去個別控制 合併後的 Mesh 無法單獨變形、隱藏或改變材質。 只合併靜態、同材質 的物件;需要個別控制時使用 InstancedMesh分層渲染
InstancedMesh 限制 只能共用同一個幾何體與材質,且 instanceMatrix 大小受限於 GPU 記憶體。 若需要多種材質,分多個 InstancedMesh;適度調整 instanceCount,避免一次性載入過多實例。
圖集 UV 計算錯誤 UV 偏移計算不正確會導致圖形顯示錯位。 使用 工具(TexturePacker、Shoebox) 產生 atlas 並自動輸出 UV 參數;測試時將 material.map.needsUpdate = true
記憶體泄漏 合併後未釋放原始 BufferGeometry,佔用過多記憶體。 合併完成後呼叫 oldGeometry.dispose(),或使用 scene.traverse 釋放不需要的資源。
過度使用 dynamic 緩衝區 若頻繁更新 InstancedBufferAttribute,會破壞批次效益。 只在必要時更新;使用 setUsage(THREE.DynamicDrawUsage)批次更新(一次性寫入多筆資料)。

最佳實踐清單

  1. 先測試再優化:使用 renderer.infostats.js 或 Chrome DevTools 的 GPU Timeline 確認瓶頸所在。
  2. 分層批次:將「靜態」與「動態」物件分別放在不同的 Group,靜態物件使用合併或 Instancing,動態物件保留獨立 Mesh。
  3. 共享材質:盡量使用同一個 Material 實例,避免因材質差異產生額外 Draw Call。
  4. 限制貼圖尺寸:圖集大小不超過 GPU 最大紋理限制(通常 8192×8192),且保持 2 的次方。
  5. 使用 LOD(Level of Detail):遠距離物件使用低多邊形模型,減少頂點計算與渲染成本。

實際應用場景

場景 需要的技巧 範例說明
森林或草原 InstancedMesh + 低多邊形樹模型 10,000 棵樹只產生 1 個 Draw Call,配合簡易陰影提升效能。
城市街景 幾何合併 + 圖集 把道路、路燈、建築的靜態部件合併成大模型,使用圖集渲染門窗貼圖。
粒子系統 InstancedMesh + 動態 BufferAttributes 1,000 粒子使用同一幾何體,僅改變位置、顏色、大小等屬性。
UI 圖示 圖集 + 2D Plane 透過單一材質渲染多個 UI 按鈕,減少 UI 層的 Draw Call。
VR/AR 大型場景 多層 LOD + Instancing 近距離使用高細節模型,遠距離切換為低細節且使用 Instancing,保持流暢。

總結

降低 Draw Call 是提升 Three.js 應用效能的關鍵手段。透過 合併幾何體、GPU Instancing、圖集與材質分組,我們可以把原本上千甚至上萬筆的繪製指令,縮減到僅僅數十筆,讓 CPU 與 GPU 的工作負載更加均衡。

在實務開發中,先測試、再分層批次、最後選擇合適的最佳化策略,才能兼顧效能與可維護性。掌握本文的概念與範例,你將能在 Three.js 專案中輕鬆應對大型場景、密集物件與高互動需求,打造流暢且具視覺衝擊力的 Web 3D 體驗。

祝你開發順利,玩轉 Three.js!