本文 AI 產出,尚未審核

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_URLprocess.env.PUBLIC_URL)或 設定檔 統一管理根路徑,避免硬寫路徑。

3.2 使用建構工具產生資產清單

  • Webpackrequire.contextfile-loader 可自動產生映射表。
  • Viteimport.meta.glob 允許一次性匯入整個資料夾,並返回 Promise。

這些功能讓我們可以 一次性載入所有資產的路徑,再由程式自行決定何時載入。

4. 建立「資產管理器」

為了讓程式碼保持乾淨,建議封裝一個 AssetManager(或 ResourceLoader)類別,負責:

  1. 快取 已載入的資產(避免重複下載)。
  2. 錯誤處理(統一回報、重試機制)。
  3. 釋放 不再使用的資源(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; // 交給上層處理
  }
}

說明

  • loadAsyncGLTFLoader 內建的 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 最新版,並參考官方升級指南

最佳實踐總結

  1. 統一根路徑:透過環境變數或設定檔一次設定,避免硬編碼。
  2. 封裝 Loader:建立 AssetManager,集中快取、錯誤處理與資源釋放。
  3. 使用 Promise / async:讓載入流程可串接 await,保持程式碼線性。
  4. 分批/懶載入:只在需要時才載入大型模型或高解析度貼圖。
  5. 快取與釋放:對已載入的資源做快取,離開場景時務必 dispose()
  6. 建構工具結合:利用 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 領域的長期夥伴。

祝開發順利,玩得開心! 🎉