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(),並移除事件監聽器。 |
最佳實踐小結:
- 模組化:每個功能 (Scene、Controller、Config、Utils) 各自獨立檔案。
- 單一職責:避免在同一檔案內同時處理 UI、渲染、資料載入。
- 使用 TypeScript(可選):提供型別檢查,減少參數傳遞錯誤。
- 環境變數:透過
.env或DefinePlugin管理開發 / 生產設定。 - 自動化測試:使用 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設定關卡特有的光源與相機參數。
- 為每個關卡建立 獨立的 Scene 模組 (
3. 資料視覺化儀表板
- 需求:即時顯示大量點雲資料,使用者可切換不同的視角與濾鏡。
- 實作:
config/appConfig.js放置 API endpoint、點雲顏色映射設定。utils/helpers.js包含 點雲資料解析 與 緩衝區幾何 (BufferGeometry) 建構。controller/orbitController.js允許使用者自由遨遊點雲。- 透過 Web Workers 把資料處理搬到背景執行,保持 UI 流暢。
總結
將 場景、控制器、配置檔 分離是 Three.js 專案工程化的基礎步驟。透過模組化設計,我們可以:
- 提升程式碼可讀性:每個檔案只負責單一職責,開發者能快速定位問題。
- 加速開發與重用:設定集中管理、場景與控制器可在不同專案間直接搬移。
- 降低維護成本:修改設定或替換控制器不會波及其他部份,且容易寫單元測試。
本文提供了 完整的目錄結構、設定檔範例、場景與控制器實作,以及 常見陷阱與最佳實踐,希望能幫助你在日後的 Three.js 專案中,快速建立乾淨、可擴充的開發環境。只要遵守 單一職責、模組化、資源釋放 三大原則,你的 3D 網頁應用將會更健壯、更易於維護,也更能應對日益複雜的需求。祝開發順利! 🚀