本文 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.aspectrenderer.setSize
重複載入資源 每次切換場景時再次載入相同的模型或貼圖,浪費記憶體。 建立 資源管理器 (ResourceManager),在第一次載入後快取,之後直接取用。
未釋放 WebGL 相關資源 離開頁面或切換場景時沒有呼叫 dispose(),導致 GPU 記憶體持續增長。 dispose() 中釋放 geometry.dispose()material.dispose()renderer.dispose(),並移除事件監聽器。

最佳實踐小結

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

實際應用場景

1. 互動式產品展示

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

2. 多場景遊戲關卡

  • 需求:一個簡易的 WebGL 小遊戲,包含「主選單」與「關卡」兩個場景。
  • 實作
    • 為每個關卡建立 獨立的 Scene 模組 (scene/Level1.jsscene/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 網頁應用將會更健壯、更易於維護,也更能應對日益複雜的需求。祝開發順利! 🚀