Three.js 效能最佳化:LOD(Level of Detail)
簡介
在 3D 網頁應用中,畫面渲染的流暢度往往直接影響使用者體驗。即使硬體效能不斷提升,大量的多邊形、貼圖與光照計算仍會讓瀏覽器的幀率跌破底線。
Level of Detail(LOD) 是一種根據相機距離自動切換模型細節等級的技術,能在保留視覺品質的同時大幅降低 GPU 負載。
對於使用 Three.js 開發的遊戲、城市模型或資料視覺化平台而言,正確運用 LOD 不僅能提升 FPS,還能減少記憶體佔用,讓應用在手機或低階筆電上也能順暢運行。本文將從概念、實作、常見陷阱與最佳實踐,帶你一步步掌握 LOD 在 Three.js 中的應用。
核心概念
1. LOD 的原理
LOD 的核心思想是「遠處的物件使用較低解析度的模型,近距離則使用高解析度模型」。
- 距離判斷:Three.js 內建
THREE.LOD會根據相機與 LOD 物件的距離自動切換。 - 多層模型:每個 LOD 物件可以掛載多個不同細節等級的 Mesh,並為每個等級設定切換距離。
這樣的切換是 即時且無縫 的,使用者不會感受到模型「跳動」或「閃爍」。
2. 為什麼要使用 LOD
| 項目 | 無 LOD | 使用 LOD |
|---|---|---|
| GPU 多邊形量 | 可能達數百萬 | 減少 30%~90%(依距離) |
| 記憶體佔用 | 大量貼圖與幾何資料 | 只載入最近的細節模型 |
| 渲染效能 | FPS 低、卡頓 | FPS 穩定提升 10~30 FPS |
| 手機端體驗 | 常常無法流暢 | 大幅提升流暢度 |
3. Three.js 中的 LOD 類別
THREE.LOD 繼承自 THREE.Object3D,主要方法包括:
| 方法 | 功能 |
|---|---|
addLevel( object, distance ) |
為 LOD 加入一個等級,distance 為切換到此等級的最遠距離 |
update( camera ) |
每一幀呼叫,根據相機位置決定顯示哪個等級 |
getObjectForDistance( distance ) |
取得指定距離下的物件(可自行使用) |
小技巧:若模型數量龐大,建議在
requestAnimationFrame迴圈的最前端先呼叫lod.update(camera),避免在渲染階段才切換造成不必要的計算。
程式碼範例
以下示範三種常見的 LOD 實作方式,從最基礎到進階應用,皆附上說明註解。
範例 1:最簡單的 LOD 設定
import * as THREE from 'three';
// 建立場景、相機與渲染器(略)
// 1. 讀取三個不同解析度的模型(此處以 BoxGeometry 為例)
const highDetail = new THREE.Mesh(
new THREE.BoxGeometry(1, 1, 1, 32, 32, 32), // 高細節
new THREE.MeshStandardMaterial({ color: 0xff0000 })
);
const midDetail = new THREE.Mesh(
new THREE.BoxGeometry(1, 1, 1, 16, 16, 16), // 中等細節
new THREE.MeshStandardMaterial({ color: 0x00ff00 })
);
const lowDetail = new THREE.Mesh(
new THREE.BoxGeometry(1, 1, 1, 4, 4, 4), // 低細節
new THREE.MeshStandardMaterial({ color: 0x0000ff })
);
// 2. 建立 LOD 物件,並加入各等級
const lod = new THREE.LOD();
lod.addLevel(highDetail, 0); // 距離 0~20 時使用高細節
lod.addLevel(midDetail, 20); // 距離 20~50 時使用中等細節
lod.addLevel(lowDetail, 50); // 超過 50 時使用低細節
scene.add(lod);
// 3. 在動畫迴圈中更新 LOD
function animate() {
requestAnimationFrame(animate);
lod.update(camera); // 依相機位置自動切換
renderer.render(scene, camera);
}
animate();
說明:
addLevel的第二個參數是「切換的最遠距離」,Three.js 會自動在距離區間內選擇最近的等級。
範例 2:從 GLTF 模型載入多層 LOD
當模型較為複雜時,我們通常會先在外部 3D 軟體(如 Blender)匯出不同細節等級的 GLTF,然後在程式中組合。
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import * as THREE from 'three';
const loader = new GLTFLoader();
const lod = new THREE.LOD();
Promise.all([
loader.loadAsync('models/tree_high.gltf'), // 高細節
loader.loadAsync('models/tree_mid.gltf'), // 中等細節
loader.loadAsync('models/tree_low.gltf') // 低細節
]).then(([high, mid, low]) => {
// GLTFScene 內的第一個子物件即為 Mesh
lod.addLevel(high.scene, 0);
lod.addLevel(mid.scene, 30);
lod.addLevel(low.scene, 70);
scene.add(lod);
});
重點:使用
loadAsync可以讓程式以Promise方式同步載入多個模型,避免「先渲染低細節再切換」的視覺閃爍。
範例 3:自訂 LOD 切換邏輯(結合視錐體剔除)
在某些大型場景中,我們希望在 視錐體外 的物件直接以最簡單的模型或完全隱藏,以下示範如何在 update 前先檢查 frustum。
import * as THREE from 'three';
const frustum = new THREE.Frustum();
const cameraViewProjectionMatrix = new THREE.Matrix4();
function updateLOD(lod, camera) {
// 1. 計算相機視錐體
camera.updateMatrixWorld(); // 確保矩陣是最新的
cameraViewProjectionMatrix.multiplyMatrices(
camera.projectionMatrix,
camera.matrixWorldInverse
);
frustum.setFromProjectionMatrix(cameraViewProjectionMatrix);
// 2. 判斷 LOD 是否在視錐體內
const boundingBox = new THREE.Box3().setFromObject(lod);
if (!frustum.intersectsBox(boundingBox)) {
// 完全不在視野內,直接隱藏
lod.visible = false;
return;
}
// 仍在視野內,才更新細節等級
lod.visible = true;
lod.update(camera);
}
// 動畫迴圈
function animate() {
requestAnimationFrame(animate);
updateLOD(lod, camera);
renderer.render(scene, camera);
}
animate();
說明:透過視錐體剔除(Frustum Culling)配合 LOD,可避免遠距離且不在視野的物件仍耗費 GPU 資源。
範例 4:使用 InstancedMesh 搭配 LOD(大量相同物件)
若場景中有成千上萬棵相同樹木,使用 InstancedMesh 能減少 draw call。下面示範如何為每個實例動態切換 LOD。
import * as THREE from 'three';
const geometryHigh = new THREE.ConeGeometry(1, 5, 16);
const geometryLow = new THREE.ConeGeometry(1, 5, 4);
const material = new THREE.MeshStandardMaterial({ color: 0x228822 });
const count = 5000;
const highInst = new THREE.InstancedMesh(geometryHigh, material, count);
const lowInst = new THREE.InstancedMesh(geometryLow, material, count);
scene.add(highInst);
scene.add(lowInst);
// 隨機產生位置
for (let i = 0; i < count; i++) {
const matrix = new THREE.Matrix4()
.makeTranslation(
(Math.random() - 0.5) * 200,
0,
(Math.random() - 0.5) * 200
);
highInst.setMatrixAt(i, matrix);
lowInst.setMatrixAt(i, matrix);
}
// 每幀根據相機距離切換顯示
function updateInstancedLOD(camera) {
const camPos = camera.position;
let highCount = 0, lowCount = 0;
for (let i = 0; i < count; i++) {
const pos = new THREE.Vector3();
highInst.getMatrixAt(i, matrix);
matrix.decompose(pos, new THREE.Quaternion(), new THREE.Vector3());
const distance = camPos.distanceTo(pos);
if (distance < 30) highCount++; else lowCount++;
}
highInst.visible = highCount > 0;
lowInst.visible = lowCount > 0;
}
技巧:
InstancedMesh只能使用同一個材質,若需要不同材質的 LOD,請先將材質合併為 Atlas。
範例 5:自動生成 LOD(利用 SimplifyModifier)
Three.js 自帶的 SimplifyModifier 能在程式執行時自動降低多邊形數量,快速產生低階模型。
import { SimplifyModifier } from 'three/examples/jsm/modifiers/SimplifyModifier.js';
import * as THREE from 'three';
function createLODFromGeometry(geometry, levels = [0.5, 0.2, 0.1]) {
const lod = new THREE.LOD();
const material = new THREE.MeshStandardMaterial({ color: 0x8888ff });
const modifier = new SimplifyModifier();
// 原始高細節
const highMesh = new THREE.Mesh(geometry.clone(), material);
lod.addLevel(highMesh, 0);
// 產生簡化等級
levels.forEach((ratio, idx) => {
const simplified = modifier.modify(geometry.clone(), Math.floor(geometry.attributes.position.count * ratio));
const mesh = new THREE.Mesh(simplified, material);
lod.addLevel(mesh, (idx + 1) * 30); // 每 30 單位距離切換一次
});
return lod;
}
// 使用範例
const boxGeo = new THREE.BoxGeometry(2, 2, 2, 64, 64, 64);
const lodBox = createLODFromGeometry(boxGeo);
scene.add(lodBox);
注意:
SimplifyModifier計算量不小,建議在 載入完成或背景執行 時才呼叫,避免卡住主執行緒。
常見陷阱與最佳實踐
| 陷阱 | 可能的影響 | 解決方案 |
|---|---|---|
| 切換距離設定不合理 | 物件在遠近切換時出現明顯的細節跳躍 | 先在開發工具(如 Chrome DevTools)觀察相機距離,使用 階梯式(0‑20‑50‑100)或 平滑過渡 的距離值 |
| 高細節模型仍被載入 | 即使遠距離不顯示,仍會佔用記憶體與載入時間 | 採用 懶載入(lazy‑load)或 動態 import,在需要時才載入高階模型 |
| 多個 LOD 同時可見 | 造成不必要的 draw call,降低效能 | 確保每個 LOD 只保留 一個子物件 可見,使用 lod.update(camera) 自動隱藏其他等級 |
| 視錐體剔除與 LOD 重複計算 | 每幀兩次距離判斷,浪費 CPU | 合併判斷:先做視錐體剔除,再決定是否需要更新 LOD |
| 貼圖尺寸過大 | 即使模型簡化,貼圖仍佔用大量 VRAM | 為不同 LOD 準備 不同解析度的貼圖(例如 1024 → 512 → 256) |
最佳實踐
- 先規劃 LOD 階層:在 3D 建模階段就決定要產生多少層,通常 2‑3 層即可覆蓋大部分需求。
- 使用
GLTF的meshopt壓縮:結合meshopt_encoder可讓高細節模型的檔案大小減少 50% 以上。 - 結合
InstancedMesh:大量相同物件(樹、岩石)時,先做實例化,再針對實例使用 LOD。 - 分段載入(Chunked Loading):將場景切成多個區塊,玩家進入區塊時才載入該區塊的 LOD。
- 測試與調校:在真機(手機、平板)上使用
stats.js或 Chrome 的 GPU 計時工具,觀察 FPS、draw call 與記憶體變化,逐步微調切換距離與模型細節。
實際應用場景
| 場景 | 為何需要 LOD | 實作要點 |
|---|---|---|
| 城市規模的 GIS 可視化 | 城市中有成千上萬的建築,遠距離只需要低解析度的外觀 | 為每棟建築產生 3 層 LOD,使用 GLTF 的 draco 壓縮,並在使用者移動相機時動態載入近距離的高階模型。 |
| 第一人稱射擊遊戲 | 玩家在高速移動時,遠處的環境不需要高細節 | 把遠景山脈、樹林做成低多邊形模型,配合 天空盒;近距離的武器與角色使用高細節模型。 |
| 虛擬實境(VR) | VR 裝置的渲染頻率要求 90 FPS 以上,資源受限 | 使用 InstancedMesh 搭配 LOD,並在每個框架只更新視野內的 LOD,減少 GPU 負載。 |
| AR 產品展示 | 手機相機畫面會與虛擬模型混合,需保持流暢 | 只在螢幕中央顯示高細節模型,四周使用低細節或簡化貼圖,以降低渲染成本。 |
| 教育與科學模擬(如分子結構) | 觀察者可能會放大或縮小模型 | 依放大倍率切換不同層級的球體或棒狀模型,確保在任何縮放階段都有合理的幾何細節。 |
總結
Level of Detail 是 Three.js 中提升效能、降低資源消耗的關鍵技術。透過 根據相機距離自動切換模型細節,我們可以在不犧牲視覺品質的前提下,將 GPU 的多邊形計算與記憶體使用量減到最小。
本文從概念說明、完整程式碼範例、常見陷阱與最佳實踐,以及實際應用場景逐層解析,幫助你在 從小型 Demo 到大型商業專案 中,靈活運用 LOD。
在實作時,記得:
- 事先規劃好 LOD 階層與切換距離。
- 結合視錐體剔除與懶載入,避免不必要的計算。
- 使用
InstancedMesh、SimplifyModifier或外部工具(如 Blender)產生低階模型。 - 在真機上測試、微調,確保 FPS、draw call 與記憶體皆在可接受範圍。
只要掌握這些要點,你的 Three.js 作品就能在各種裝置上保持 流暢、穩定且視覺上令人滿意 的表現。祝開發順利,玩得開心!