Three.js 工程化與專案結構
主題:動態載入 Asset 與路徑管理
簡介
在現代 WebGL 開發中,Three.js 已成為製作 3D 互動體驗的首選框架。隨著專案規模日益擴大,單純在程式碼中硬寫資源路徑已無法滿足 維護性、可擴充性 與 效能 的需求。
動態載入(dynamic loading)不僅可以讓瀏覽器只在需要時才下載模型、貼圖或環境貼圖,降低首屏載入時間;同時結合 路徑管理(path management)與建構工具(如 Webpack、Vite),可以避免路徑錯誤、支援快取與 CDN 部署,讓專案在團隊協作與部署階段更為順暢。
本篇文章將從 概念說明、實作範例、常見陷阱 與 最佳實踐 四個面向,完整剖析在 Three.js 專案中如何安全、有效地動態載入資產並管理路徑,適合剛踏入 Three.js 的初學者,也能為已有基礎的開發者提供可直接套用的實務技巧。
核心概念
1. 為什麼要動態載入資產
| 需求 | 靜態載入(硬寫路徑) | 動態載入(程式化) |
|---|---|---|
| 首屏載入速度 | 需一次載入全部檔案,易造成阻塞 | 只載入當前場景所需資源,減少等待 |
| 記憶體管理 | 所有資源同時佔用記憶體 | 釋放不再使用的資源,降低記憶體佔用 |
| 版本控制 | 檔名變動需手動更新程式碼 | 透過 manifest 或 import.meta.glob 自動同步 |
| 多語系 / 主題 | 每個版本都要寫一份程式 | 只改變路徑設定,即可切換資源 |
結論:在大型或需要頻繁更新素材的 Three.js 專案中,動態載入是提升效能與維護性的關鍵。
2. Three.js 常見的 Loader
Three.js 本身提供多種 Loader,用於載入不同類型的資產。以下列出最常用的幾種,並說明其 API 重點。
| Loader | 主要用途 | 典型副檔名 |
|---|---|---|
GLTFLoader |
3D 模型(含動畫、材質) | .gltf、.glb |
TextureLoader |
2D 紋理、貼圖 | .jpg、.png |
CubeTextureLoader |
環境貼圖(六面立方體) | .png、.jpg |
FontLoader |
文字字型(Three.js 文字幾何) | .json |
DRACOLoader |
GLTF 壓縮模型(DRACO) | .drc |
小技巧:所有 Loader 都支援 Promise 包裝(或直接使用
loadAsync),配合async/await可讓程式碼更易讀。
3. 路徑管理的策略
3.1 絕對路徑 vs 相對路徑
- 相對路徑(
./assets/model.glb)在本機開發時直觀,但在部署到 CDN 或子目錄時易失效。 - 絕對路徑(
/static/assets/model.glb)依賴於網站根目錄,較適合 CDN。
建議:使用 環境變數(
import.meta.env.BASE_URL、process.env.PUBLIC_URL)或 設定檔 統一管理根路徑,避免硬寫路徑。
3.2 使用建構工具產生資產清單
- Webpack:
require.context或file-loader可自動產生映射表。 - Vite:
import.meta.glob允許一次性匯入整個資料夾,並返回 Promise。
這些功能讓我們可以 一次性載入所有資產的路徑,再由程式自行決定何時載入。
4. 建立「資產管理器」
為了讓程式碼保持乾淨,建議封裝一個 AssetManager(或 ResourceLoader)類別,負責:
- 快取 已載入的資產(避免重複下載)。
- 錯誤處理(統一回報、重試機制)。
- 釋放 不再使用的資源(
dispose())。
以下示範一個簡易的 AssetManager,支援 GLTF 與貼圖的動態載入。
程式碼範例
範例 1:使用 GLTFLoader 搭配 async/await
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js';
// 建立 loader 實例,並設定 DRACO 解壓縮支援
const dracoLoader = new DRACOLoader();
dracoLoader.setDecoderPath('/libs/draco/'); // <-- 這裡使用絕對路徑
const gltfLoader = new GLTFLoader();
gltfLoader.setDRACOLoader(dracoLoader);
/**
* 以 Promise 包裝的 GLTF 載入函式
* @param {string} url 模型檔案的相對或絕對路徑
* @returns {Promise<THREE.Group>}
*/
export async function loadGLTF(url) {
try {
const gltf = await gltfLoader.loadAsync(url);
console.log('✅ GLTF 載入成功', url);
return gltf.scene; // 回傳場景物件
} catch (err) {
console.error('❌ GLTF 載入失敗', url, err);
throw err; // 交給上層處理
}
}
說明:
loadAsync為GLTFLoader內建的 Promise 版,搭配async/await可讓程式流程更直觀。DRACOLoader必須先設定解碼器路徑,否則會因 CORS 或路徑錯誤拋出例外。
範例 2:使用 TextureLoader 與環境變數管理路徑
import { TextureLoader } from 'three';
import { RGBELoader } from 'three/examples/jsm/loaders/RGBELoader.js';
// 取得專案根路徑(Vite 範例)
const ASSET_BASE = import.meta.env.BASE_URL || '/';
// 建立 loader 實例
const textureLoader = new TextureLoader();
export async function loadTexture(relativePath) {
const fullPath = `${ASSET_BASE}assets/textures/${relativePath}`;
try {
const texture = await textureLoader.loadAsync(fullPath);
texture.encoding = THREE.sRGBEncoding; // 依需求設定編碼
console.log('✅ Texture 載入', fullPath);
return texture;
} catch (e) {
console.error('❌ Texture 載入失敗', fullPath, e);
throw e;
}
}
重點:
- 透過
import.meta.env.BASE_URL統一管理根路徑,部署到子目錄或 CDN 時只要修改環境變數即可。sRGBEncoding能避免貼圖顏色變暗的常見問題。
範例 3:CubeTextureLoader 載入環境貼圖(HDR)
import { CubeTextureLoader, Texture } from 'three';
import { RGBELoader } from 'three/examples/jsm/loaders/RGBELoader.js';
const envBase = import.meta.env.BASE_URL + 'assets/env/';
export async function loadEnvironmentMap() {
// 若使用 HDR,建議使用 RGBELoader
const hdrLoader = new RGBELoader();
const hdr = await hdrLoader.loadAsync(`${envBase}studio.hdr`);
hdr.mapping = THREE.EquirectangularReflectionMapping;
console.log('✅ HDR 環境貼圖載入完成');
// 若使用 CubeTexture(六面),示範如下
const cubeLoader = new CubeTextureLoader();
const cubeTexture = await cubeLoader.loadAsync([
`${envBase}px.jpg`,
`${envBase}nx.jpg`,
`${envBase}py.jpg`,
`${envBase}ny.jpg`,
`${envBase}pz.jpg`,
`${envBase}nz.jpg`,
]);
console.log('✅ CubeTexture 環境貼圖載入完成');
return { hdr, cubeTexture };
}
說明:
- HDR 環境貼圖提供更真實的光照資訊,需配合
EquirectangularReflectionMapping。- 若專案僅支援舊版瀏覽器,可備用 CubeTexture 作為 fallback。
範例 4:資產清單(manifest)與統一載入函式
假設我們在 public/assets/manifest.json 中維護所有資產的相對路徑:
{
"models": {
"car": "models/car.glb",
"tree": "models/tree.glb"
},
"textures": {
"ground": "textures/ground.jpg",
"metal": "textures/metal.png"
}
}
載入與使用:
import { loadGLTF } from './loaders/gltf.js';
import { loadTexture } from './loaders/texture.js';
let manifest = null;
/**
* 初始化資產清單(一次性載入)
*/
export async function initAssetManifest() {
const resp = await fetch(`${import.meta.env.BASE_URL}assets/manifest.json`);
manifest = await resp.json();
console.log('✅ Manifest 已載入', manifest);
}
/**
* 依照類型與名稱取得資產
* @param {'model'|'texture'} type
* @param {string} name
*/
export async function getAsset(type, name) {
if (!manifest) await initAssetManifest();
const path = manifest[`${type}s`][name];
if (!path) throw new Error(`Asset not found: ${type}/${name}`);
switch (type) {
case 'model':
return await loadGLTF(path);
case 'texture':
return await loadTexture(path);
default:
throw new Error(`Unsupported asset type: ${type}`);
}
}
優點:
- 資產路徑集中管理,一次修改即可影響全專案。
- 自動快取:
manifest只會載入一次,減少重複請求。
範例 5:Vite import.meta.glob 自動產生 Loader
Vite 內建的 import.meta.glob 能一次性匯入目錄下所有檔案,回傳一個 函式映射,非常適合製作「動態載入」的資產表。
// src/assets/index.js
// 只載入 .glb 檔案,產生 { './car.glb': () => Promise<Module>, ... }
const gltfModules = import.meta.glob('../assets/models/*.glb');
// 建立簡易的 AssetManager
export const AssetManager = {
cache: new Map(),
/**
* 取得 GLTF 模型
* @param {string} fileName 如 'car.glb'
*/
async getModel(fileName) {
if (this.cache.has(fileName)) return this.cache.get(fileName);
const loader = gltfModules[`../assets/models/${fileName}`];
if (!loader) throw new Error(`Model not found: ${fileName}`);
const module = await loader(); // 動態 import,返回 { default: URL }
const url = module.default;
const model = await loadGLTF(url); // 前面範例的 loadGLTF
this.cache.set(fileName, model);
return model;
},
};
說明:
import.meta.glob只在開發與建構階段執行,最終會把檔案打包成 code‑splitting 的 chunk,只有在呼叫loader()時才會下載。- 透過
AssetManager.cache可避免重複載入,同時保留 快取 與 釋放 的彈性。
常見陷阱與最佳實踐
| 陷阱 | 可能的結果 | 解決方案 / 最佳實踐 |
|---|---|---|
硬寫相對路徑 (./model.glb) |
部署到子目錄或 CDN 時 404 | 使用環境變數或 import.meta.env.BASE_URL 統一根路徑 |
| 未設定 CORS | Cross-Origin Request Blocked 錯誤 |
確保資源伺服器允許跨域,或使用同源 CDN |
| 大量模型同時載入 | 主執行緒卡住、畫面卡頓 | 使用 分批載入(Lazy Load)或 Progressive Loading(先載低解析度,再換高解析度) |
忘記釋放貼圖 (texture.dispose()) |
記憶體洩漏、GPU 超載 | 在不需要時手動呼叫 dispose(),或使用資產管理器自動回收 |
| Loader 錯誤未捕獲 | 程式崩潰、使用者無法得知錯誤 | 每個 loadAsync 包裝 try/catch,並回傳可讀的錯誤訊息 |
| 使用舊版 Three.js | 新的 Loader API 不相容 | 盡量使用 npm 最新版,並參考官方升級指南 |
最佳實踐總結
- 統一根路徑:透過環境變數或設定檔一次設定,避免硬編碼。
- 封裝 Loader:建立
AssetManager,集中快取、錯誤處理與資源釋放。 - 使用 Promise / async:讓載入流程可串接
await,保持程式碼線性。 - 分批/懶載入:只在需要時才載入大型模型或高解析度貼圖。
- 快取與釋放:對已載入的資源做快取,離開場景時務必
dispose()。 - 建構工具結合:利用 Webpack/Vite 的自動匯入功能,讓路徑與檔案同步。
實際應用場景
1. 產品展示平台(Product Configurator)
- 需求:使用者可以在網頁上切換不同的配件、材質與顏色。
- 實作:將每個配件、材質寫入
manifest.json,在使用者點擊時呼叫AssetManager.getModel('wheel.glb')或AssetManager.getTexture('leather.jpg'),並在切換時釋放舊的貼圖。 - 效益:首次載入只下載車體模型,其他配件在需求時才下載,明顯縮短載入時間。
2. 虛擬實境(WebVR)導覽
- 需求:在大型場景(如博物館)內,需要逐步載入不同展廳的模型與環境貼圖。
- 實作:使用
import.meta.glob把每個展廳的 GLB 檔案分成獨立 chunk,使用者走到門口時觸發AssetManager.getModel('gallery1.glb'),同時預載相鄰展廳的貼圖。 - 效益:避免一次性下載所有模型,降低 VR 裝置的記憶體壓力。
3. 多語系與主題切換
- 需求:根據使用者語系顯示不同的文字貼圖或 UI 圖示。
- 實作:在
manifest.json中分別列出en/,zh/的路徑,載入時根據navigator.language取對應資產。 - 效益:資產路徑與語系解耦,新增語系只需要在 manifest 中補檔即可。
總結
在 Three.js 專案中,動態載入資產與路徑管理是提升效能、降低維護成本的關鍵。本文從概念、實作、常見陷阱與最佳實踐四大面向,提供了:
- Loader 的 Promise 用法(
loadAsync) - 環境變數或 manifest 統一管理路徑
- 資產管理器(AssetManager) 的快取與釋放機制
- 建構工具(Webpack / Vite) 與自動匯入的結合技巧
結合上述技巧,開發者可以在 保持程式碼可讀性 的同時,讓大型 3D 網頁應用在 首次載入速度、記憶體使用與跨平台部署 等面向表現更佳。未來若有更進階的需求(如 Streaming、Progressive GLTF、WebGPU),同樣可以在此基礎上擴充,讓 Three.js 成為您在 Web 3D 領域的長期夥伴。
祝開發順利,玩得開心! 🎉