本文 AI 產出,尚未審核

Three.js 工程化與專案結構

主題:分離場景、控制器、配置檔


簡介

在使用 Three.js 開發互動 3D 網頁應用時,隨著功能的增長,單一檔案往往會變得又長又雜。
若把所有程式碼堆在 index.js 中,雖然一開始可以快速跑起來,但隨著場景變得複雜、控制器增多、設定項目需要調整,維護成本會急速上升,甚至容易出現 bug 難以追蹤。

將 場景 (Scene)、控制器 (Controller) 與配置檔 (Config) 分離成獨立模組,是實務開發中最常見的 工程化 手法。這樣的結構不僅讓程式碼更具可讀性,也方便團隊協作、重用與單元測試。本文將從概念說明、實作範例、常見陷阱與最佳實踐,帶領你一步步打造乾淨、可維護的 Three.js 專案。


核心概念

1. 為什麼要分離?

需求 不分離的問題 分離後的好處
可讀性 大檔案中混雜「建立場景」與「UI 控制」的程式碼,閱讀時必須不斷跳躍 每個模組只負責單一職責,檔案大小適中,直接看檔名就知道功能
可維護性 調整相機參數或光源時,可能不小心改到其他地方 設定集中在 config.js,修改一次即可全局生效
重用性 另一個專案想使用相同的場景,需要全部 copy‑paste 只要匯入 scene.js,即可在不同專案重用
測試 難以對單一功能寫測試 每個模組都能獨立測試,例如測試控制器的滑鼠事件是否正確觸發

核心原則:單一職責 (Single Responsibility Principle)。每個檔案只做一件事。


2. 專案目錄範例

my-threejs-app/
│
├─ src/
│   ├─ index.js          // 入口,負責組裝
│   ├─ config/
│   │   └─ appConfig.js  // 全域設定
│   ├─ scene/
│   │   └─ mainScene.js  // 建立、管理 Three.js Scene
│   ├─ controller/
│   │   ├─ orbitController.js   // OrbitControls 包裝
│   │   └─ guiController.js     // dat.GUI 包裝
│   └─ utils/
│       └─ helpers.js    // 共用工具函式
│
├─ public/
│   └─ index.html
│
├─ package.json
└─ webpack.config.js

說明:src 內的每個子資料夾對應一個概念,index.js 只負責「匯入」與「組合」各模組。


3. 配置檔 (appConfig.js)

配置檔的目的在於 集中管理所有可調整的參數,例如相機位置、光源強度、模型路徑等。使用純 JavaScript 物件或 export const,讓其他模組直接 import。

// src/config/appConfig.js
export const APP_CONFIG = {
  // ---------- Renderer ----------
  antialias: true,
  pixelRatio: window.devicePixelRatio,
  clearColor: 0x111111,

  // ---------- Camera ----------
  camera: {
    fov: 60,
    near: 0.1,
    far: 1000,
    position: { x: 0, y: 5, z: 10 },
  },

  // ---------- Light ----------
  ambientLight: {
    color: 0xffffff,
    intensity: 0.5,
  },
  directionalLight: {
    color: 0xffffff,
    intensity: 1,
    position: { x: 5, y: 10, z: 7 },
  },

  // ---------- Model ----------
  modelPath: '/assets/models/scene.glb',
};

技巧:在開發階段可以使用 環境變數 或 JSON 檔案,讓 webpack 在打包時自動替換不同環境的設定。


4. 場景模組 (mainScene.js)

此模組負責 建立 Renderer、Camera、Scene、光源,並提供 init()、update()、dispose() 三個常見介面。

// src/scene/mainScene.js
import * as THREE from 'three';
import { APP_CONFIG } from '../config/appConfig.js';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import { loadGLTF } from '../utils/helpers.js';

class MainScene {
  constructor(container) {
    this.container = container;
    this.clock = new THREE.Clock();
    this._setupRenderer();
    this._setupCamera();
    this._setupScene();
    this._setupLights();
    this._setupControls();
  }

  // ----------------- Private Methods -----------------
  _setupRenderer() {
    this.renderer = new THREE.WebGLRenderer({ antialias: APP_CONFIG.antialias });
    this.renderer.setPixelRatio(APP_CONFIG.pixelRatio);
    this.renderer.setClearColor(APP_CONFIG.clearColor);
    this.container.appendChild(this.renderer.domElement);
    window.addEventListener('resize', () => this._onWindowResize());
  }

  _setupCamera() {
    const cfg = APP_CONFIG.camera;
    this.camera = new THREE.PerspectiveCamera(
      cfg.fov,
      window.innerWidth / window.innerHeight,
      cfg.near,
      cfg.far
    );
    this.camera.position.set(cfg.position.x, cfg.position.y, cfg.position.z);
  }

  _setupScene() {
    this.scene = new THREE.Scene();
  }

  _setupLights() {
    const amb = new THREE.AmbientLight(
      APP_CONFIG.ambientLight.color,
      APP_CONFIG.ambientLight.intensity
    );
    this.scene.add(amb);

    const dirCfg = APP_CONFIG.directionalLight;
    const dir = new THREE.DirectionalLight(dirCfg.color, dirCfg.intensity);
    dir.position.set(dirCfg.position.x, dirCfg.position.y, dirCfg.position.z);
    this.scene.add(dir);
  }

  _setupControls() {
    // 直接使用 OrbitControls,若要自訂則可抽成 controller 模組
    this.controls = new OrbitControls(this.camera, this.renderer.domElement);
    this.controls.enableDamping = true;
  }

  _onWindowResize() {
    const width = window.innerWidth;
    const height = window.innerHeight;
    this.camera.aspect = width / height;
    this.camera.updateProjectionMatrix();
    this.renderer.setSize(width, height);
  }

  // ----------------- Public API -----------------
  async init() {
    // 載入模型(使用 utils/helpers.js 中的 loadGLTF)
    const gltf = await loadGLTF(APP_CONFIG.modelPath);
    this.scene.add(gltf.scene);
    this.renderer.setSize(window.innerWidth, window.innerHeight);
  }

  update() {
    const delta = this.clock.getDelta();
    this.controls.update(delta);
    this.renderer.render(this.scene, this.camera);
  }

  dispose() {
    this.renderer.dispose();
    this.controls.dispose();
    // 清理其他資源...
  }
}

export default MainScene;

要點

  • 私有方法 (_setup*) 讓 constructor 保持簡潔。
  • init() 使用 async/await 讓模型載入完成後才開始渲染。
  • update() 只負責每幀的更新,方便在 index.js 中透過 requestAnimationFrame 呼叫。

5. 控制器模組 (orbitController.js)

若專案需要多種控制器(Orbit、FirstPerson、PointerLock 等),把它們封裝成獨立類別,可在 index.js 中自由切換。

// src/controller/orbitController.js
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';

export class OrbitController {
  /**
   * @param {THREE.Camera} camera
   * @param {HTMLElement} domElement
   * @param {Object} [options] 可自行擴充
   */
  constructor(camera, domElement, options = {}) {
    this.controls = new OrbitControls(camera, domElement);
    this.controls.enableDamping = options.enableDamping ?? true;
    this.controls.dampingFactor = options.dampingFactor ?? 0.1;
    this.controls.minDistance = options.minDistance ?? 5;
    this.controls.maxDistance = options.maxDistance ?? 50;
  }

  update(delta) {
    this.controls.update(delta);
  }

  dispose() {
    this.controls.dispose();
  }
}

使用方式(在 index.js):

import MainScene from './scene/mainScene.js';
import { OrbitController } from './controller/orbitController.js';

const container = document.getElementById('webgl');
const app = new MainScene(container);

await app.init(); // 載入模型

// 以自訂的 OrbitController 取代內建的
app.controls = new OrbitController(app.camera, app.renderer.domElement, {
  minDistance: 2,
  maxDistance: 30,
});

function animate() {
  requestAnimationFrame(animate);
  app.update(); // 內部會呼叫 app.controls.update()
}
animate();

6. 工具函式 (helpers.js)

把 重複使用的程式碼(如 GLTF 載入、材質設定)抽成 utils,讓主模組更乾淨。

// src/utils/helpers.js
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js';

/**
 * 載入 GLTF / GLB 模型,支援 Draco 壓縮
 * @param {string} url 模型檔案路徑
 * @returns {Promise<THREE.GLTF>}
 */
export function loadGLTF(url) {
  const dracoLoader = new DRACOLoader();
  dracoLoader.setDecoderPath('/libs/draco/'); // 依實際路徑調整

  const loader = new GLTFLoader();
  loader.setDRACOLoader(dracoLoader);

  return new Promise((resolve, reject) => {
    loader.load(
      url,
      gltf => resolve(gltf),
      undefined,
      err => reject(err)
    );
  });
}

/**
 * 為所有 Mesh 設定相同的材質
 * @param {THREE.Object3D} root
 * @param {THREE.Material} material
 */
export function applyMaterial(root, material) {
  root.traverse(child => {
    if (child.isMesh) child.material = material;
  });
}

常見陷阱與最佳實踐

陷阱 說明 解決方案
全域變數污染 直接在 index.html 加 <script>,容易把 Three.js 變數掛到 window 上。 使用 ES6 modules (type="module"),所有變數必須 import / export。
相機與控制器不同步 初始化時相機位置改了,但控制器仍維持舊的目標點,導致視角跳動。 在建立控制器後 立即呼叫 controls.update(),或在 reset 時同步 controls.target。
硬編碼尺寸 把畫布寬高寫死,導致在手機或視窗縮放時畫面變形。 監聽 resize 事件,使用 window.innerWidth/innerHeight 動態設定 camera.aspect 與 renderer.setSize。
重複載入資源 每次切換場景時再次載入相同的模型或貼圖,浪費記憶體。 建立 資源管理器 (ResourceManager),在第一次載入後快取,之後直接取用。
未釋放 WebGL 相關資源 離開頁面或切換場景時沒有呼叫 dispose(),導致 GPU 記憶體持續增長。 在 dispose() 中釋放 geometry.dispose()、material.dispose()、renderer.dispose(),並移除事件監聽器。

最佳實踐小結:

  1. 模組化:每個功能 (Scene、Controller、Config、Utils) 各自獨立檔案。
  2. 單一職責:避免在同一檔案內同時處理 UI、渲染、資料載入。
  3. 使用 TypeScript(可選):提供型別檢查,減少參數傳遞錯誤。
  4. 環境變數:透過 .env 或 DefinePlugin 管理開發 / 生產設定。
  5. 自動化測試:使用 Jest + three‑mock,對 helpers.js、config 進行單元測試。

實際應用場景

1. 互動式產品展示

  • 需求:客戶想要在網站嵌入 3D 產品模型,允許使用者旋轉、放大、切換材質。
  • 實作:
    • config/appConfig.js 控制模型路徑與預設相機位置。
    • scene/mainScene.js 負責載入模型與光源。
    • controller/orbitController.js 提供旋轉與縮放。
    • 另建 controller/guiController.js 使用 dat.GUI 讓使用者即時切換材質。

2. 多場景遊戲關卡

  • 需求:一個簡易的 WebGL 小遊戲,包含「主選單」與「關卡」兩個場景。
  • 實作:
    • 為每個關卡建立 獨立的 Scene 模組 (scene/Level1.js、scene/Level2.js)。
    • index.js 只負責根據使用者選擇 動態載入 相應的 Scene 類別。
    • 透過 config/appConfig.js 設定關卡特有的光源與相機參數。

3. 資料視覺化儀表板

  • 需求:即時顯示大量點雲資料,使用者可切換不同的視角與濾鏡。
  • 實作:
    • config/appConfig.js 放置 API endpoint、點雲顏色映射設定。
    • utils/helpers.js 包含 點雲資料解析 與 緩衝區幾何 (BufferGeometry) 建構。
    • controller/orbitController.js 允許使用者自由遨遊點雲。
    • 透過 Web Workers 把資料處理搬到背景執行,保持 UI 流暢。

總結

將 場景、控制器、配置檔 分離是 Three.js 專案工程化的基礎步驟。透過模組化設計,我們可以:

  • 提升程式碼可讀性:每個檔案只負責單一職責,開發者能快速定位問題。
  • 加速開發與重用:設定集中管理、場景與控制器可在不同專案間直接搬移。
  • 降低維護成本:修改設定或替換控制器不會波及其他部份,且容易寫單元測試。

本文提供了 完整的目錄結構、設定檔範例、場景與控制器實作,以及 常見陷阱與最佳實踐,希望能幫助你在日後的 Three.js 專案中,快速建立乾淨、可擴充的開發環境。只要遵守 單一職責、模組化、資源釋放 三大原則,你的 3D 網頁應用將會更健壯、更易於維護,也更能應對日益複雜的需求。祝開發順利! 🚀