本文 AI 產出,尚未審核

Three.js 工程化與專案結構:使用 Vite / Webpack 打包


簡介

在開發 Three.js 互動 3D 應用時,單純把所有程式碼寫在一個 HTML 檔案裡雖然能快速驗證概念,卻不利於維護與擴充。隨著模型、材質、特效、UI 等資源的增加,專案的檔案結構、模組化、以及建置流程變得相當重要。

  • ViteWebpack 是目前前端社群最常使用的兩套打包工具。它們不僅能將 ES 模組、CSS、圖片、GLTF 等資源自動轉換成瀏覽器可直接載入的檔案,還提供熱重載(HMR)、代碼分割、Tree‑shaking 等效能優化功能,讓開發 Three.js 時的迭代速度與最終產出品質都大幅提升。

本篇文章將從 專案初始化設定 Vite / Webpack常見問題 以及 實務案例 四個面向,逐步說明如何在 Three.js 專案中正確運用這兩套工具,讓你的 3D 應用從「雜亂」變成「可維護、可擴充」的工程化作品。


核心概念

1. 為什麼需要打包工具?

項目 手寫 <script> 方式 使用 Vite / Webpack
模組化 只能使用全域變數或 IIFE,難以管理依賴 支援 ES Modules、CommonJS,依賴自動解析
資源載入 必須自行寫 loader 讀取 GLTF、HDR 等 內建 loader,支援圖片、字體、模型等
開發體驗 每次修改都要手動重新整理 Hot Module Replacement (HMR) 即時更新
效能優化 無法自動壓縮、分割程式碼 Tree‑shaking、Code Splitting、壓縮 (Terser)
跨平台 只能在瀏覽器執行 支援 Node、Electron、SSR 等多種環境

結論:在任何稍微複雜的 Three.js 專案中,使用 Vite 或 Webpack 幾乎是必備的基礎設施。


2. Vite 基本設定

Vite 以原生 ES 模組為核心,啟動速度極快,特別適合開發階段需要頻繁切換場景或調整 Shader 的情境。

2.1 初始化專案

# 建立一個新的 Vite + Three.js 專案
npm create vite@latest three-vite-demo -- --template vanilla
cd three-vite-demo
npm install
npm install three

這裡使用 vanilla 模板,之後會自行加入 Three.js 相關程式碼。

2.2 建立專案結構

three-vite-demo/
├─ public/               # 靜態資源 (favicon、模型、HDR)
│   └─ models/
│       └─ scene.gltf
├─ src/
│   ├─ assets/           # 直接在程式碼中 import 的資源
│   │   └─ textures/
│   │       └─ wood.jpg
│   ├─ components/       # 可重用的 Three.js 元件 (OrbitControls、PostProcessing)
│   │   └─ OrbitControls.js
│   ├─ shaders/          # 自訂 GLSL 程式碼
│   │   ├─ vertex.glsl
│   │   └─ fragment.glsl
│   ├─ main.js           # 入口檔案
│   └─ style.css
├─ vite.config.js        # Vite 設定
└─ index.html

2.3 Vite 設定檔

vite.config.js 中加入 GLTFHDR 等 loader,讓 Vite 能正確處理這些二進位檔案。

import { defineConfig } from 'vite';
import { resolve } from 'path';

export default defineConfig({
  // 設定別名,方便 import
  resolve: {
    alias: {
      '@': resolve(__dirname, 'src'),
    },
  },
  // 靜態資源的處理
  assetsInclude: ['**/*.gltf', '**/*.glb', '**/*.hdr'],
  // 開發伺服器設定
  server: {
    open: true, // 自動開啟瀏覽器
    port: 3000,
  },
});

2.4 主程式範例

// src/main.js
import * as THREE from 'three';
import { OrbitControls } from '@/components/OrbitControls.js';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import woodTexture from '@/assets/textures/wood.jpg';

// 建立場景、相機、渲染器
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(
  75,
  window.innerWidth / window.innerHeight,
  0.1,
  1000
);
camera.position.set(0, 1.5, 3);

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

// 加入軌道控制
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;

// 加載紋理
const texture = new THREE.TextureLoader().load(woodTexture);
const floor = new THREE.Mesh(
  new THREE.PlaneGeometry(10, 10),
  new THREE.MeshStandardMaterial({ map: texture })
);
floor.rotation.x = -Math.PI / 2;
scene.add(floor);

// 加載 GLTF 模型
const loader = new GLTFLoader();
loader.load('@/public/models/scene.gltf', (gltf) => {
  scene.add(gltf.scene);
});

// 環境光 + 平行光
scene.add(new THREE.AmbientLight(0xffffff, 0.6));
const dirLight = new THREE.DirectionalLight(0xffffff, 1);
dirLight.position.set(5, 10, 7);
scene.add(dirLight);

// 窗口調整
window.addEventListener('resize', () => {
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(window.innerWidth, window.innerHeight);
});

// 渲染循環
function animate() {
  requestAnimationFrame(animate);
  controls.update();
  renderer.render(scene, camera);
}
animate();

以上程式碼展示了 Vite 如何透過 ES 模組直接匯入 Three.js、GLTFLoader、以及本地圖片,且不需要額外的 Webpack loader 設定。


3. Webpack 基本設定

Webpack 的優勢在於 高度可客製化,適合需要多階段編譯(如 TypeScript + GLSL)或與後端整合的專案。

3.1 初始化專案

mkdir three-webpack-demo
cd three-webpack-demo
npm init -y
npm install --save-dev webpack webpack-cli webpack-dev-server \
  html-webpack-plugin clean-webpack-plugin \
  css-loader style-loader \
  file-loader
npm install three

3.2 建立檔案結構

three-webpack-demo/
├─ src/
│   ├─ assets/
│   │   └─ textures/
│   │       └─ metal.jpg
│   ├─ shaders/
│   │   ├─ vertex.glsl
│   │   └─ fragment.glsl
│   ├─ index.js          # 入口
│   └─ index.html
├─ webpack.config.js
└─ .babelrc (若使用 Babel)

3.3 Webpack 設定檔

// webpack.config.js
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');

module.exports = (env, argv) => {
  const isProd = argv.mode === 'production';

  return {
    entry: './src/index.js',
    output: {
      filename: isProd ? '[name].[contenthash].js' : '[name].js',
      path: path.resolve(__dirname, 'dist'),
      publicPath: '/',
    },
    resolve: {
      alias: {
        '@': path.resolve(__dirname, 'src'),
      },
      extensions: ['.js', '.json'],
    },
    module: {
      rules: [
        // 處理圖片、GLTF、HDR
        {
          test: /\.(png|jpe?g|gif|hdr|gltf|glb)$/i,
          type: 'asset/resource',
        },
        // 處理 GLSL
        {
          test: /\.(glsl|vs|fs)$/,
          use: ['raw-loader', 'glslify-loader'],
        },
        // CSS
        {
          test: /\.css$/i,
          use: ['style-loader', 'css-loader'],
        },
      ],
    },
    plugins: [
      new CleanWebpackPlugin(),
      new HtmlWebpackPlugin({
        template: './src/index.html',
        inject: 'body',
      }),
    ],
    devServer: {
      static: path.join(__dirname, 'dist'),
      compress: true,
      port: 8080,
      open: true,
      hot: true,
    },
    optimization: {
      splitChunks: {
        chunks: 'all',
      },
    },
  };
};

3.4 主程式範例

// src/index.js
import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import metalImg from '@/assets/textures/metal.jpg';
import vertexShader from '@/shaders/vertex.glsl';
import fragmentShader from '@/shaders/fragment.glsl';

// 基礎設定
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(
  60,
  window.innerWidth / window.innerHeight,
  0.1,
  1000
);
camera.position.set(0, 2, 5);

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

// 控制器
const controls = new OrbitControls(camera, renderer.domElement);
controls.enablePan = false;

// 環境光
scene.add(new THREE.AmbientLight(0xffffff, 0.5));

// 自訂材質 - 使用 GLSL
const customMaterial = new THREE.ShaderMaterial({
  vertexShader,
  fragmentShader,
  uniforms: {
    uTime: { value: 0 },
    uTexture: { value: new THREE.TextureLoader().load(metalImg) },
  },
});

// 簡單幾何體
const box = new THREE.Mesh(
  new THREE.BoxGeometry(1, 1, 1),
  customMaterial
);
scene.add(box);

// GLTF 載入
const gltfLoader = new GLTFLoader();
gltfLoader.load('/models/room.glb', (gltf) => {
  gltf.scene.position.set(0, 0, -2);
  scene.add(gltf.scene);
});

// 視窗調整
window.addEventListener('resize', () => {
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(window.innerWidth, window.innerHeight);
});

// 動畫迴圈
const clock = new THREE.Clock();
function render() {
  const elapsed = clock.getElapsedTime();
  customMaterial.uniforms.uTime.value = elapsed;
  renderer.render(scene, camera);
  requestAnimationFrame(render);
}
render();

此範例展示 Webpack 如何透過 raw-loaderglslify-loader 直接匯入 GLSL 程式碼,並在 ShaderMaterial 中使用自訂 uniform。


4. 常見陷阱與最佳實踐

陷阱 說明 解決方案 / 最佳實踐
資源路徑錯誤 Vite 會把 public 目錄視為根目錄,Webpack 則需要 file-loader 處理。 1. 使用絕對路徑 (/models/...) 於 public 中;
2. 在 Webpack output.publicPath 設為 '/'
GLTF 加載失敗 模型內部的貼圖路徑相對於模型檔案,打包後路徑可能改變。 使用 GLTFLoader.setPath() 或在 Vite/Webpack 中設定 asset/resourcegenerator.filenameassets/[name][ext]
Shader 變數未同步 在開發時忘記更新 uniforms 會導致渲染錯誤。 uniforms 放在模組外層,並在 requestAnimationFrame 中統一更新。
開發時熱重載失效 某些 Three.js 內建模組(如 OrbitControls)會在 HMR 時保持舊的實例。 module.hot.dispose 中手動銷毀舊的控制器或渲染器,或使用 import.meta.hot(Vite)/ module.hot(Webpack)清理。
建置檔案過大 未啟用 Tree‑shaking,導致整個 Three.js 全部被打包。 確保 sideEffects: falsepackage.json,或在 webpack.config.js 中使用 mode: 'production',Vite 自動執行。

4.1 HMR 清理範例(Vite)

if (import.meta.hot) {
  import.meta.hot.dispose(() => {
    // 停止渲染迴圈、釋放 WebGL 資源
    renderer.dispose();
    controls.dispose();
    // 移除 canvas
    document.body.removeChild(renderer.domElement);
  });
}

4.2 渲染器資源回收(Webpack)

if (module.hot) {
  module.hot.dispose(() => {
    renderer.forceContextLoss();
    renderer.context = null;
  });
}

5. 實際應用場景

  1. 產品展示網站

    • 使用 Vite 快速開發,結合 GLTFLoader 載入高品質模型,透過環境貼圖(HDR)提升真實感。
    • 透過 code-splitting 把每個產品頁面分割成獨立 chunk,減少首次下載大小。
  2. 互動式教育平台

    • Webpack 的多階段編譯(TS → Babel → WebGL)能確保程式碼在舊版瀏覽器仍能正常執行。
    • shader 為核心的視覺化教材,可在 webpack 中加入 glslify 讓 GLSL 支援 #include#define
  3. AR/VR 雲端渲染

    • Vite 的原生 ESM 可結合 three-mesh-uiWebXR,在開發階段即時測試 XR 互動。
    • 產出的靜態檔案可部署至 CDN,配合 service worker 實作離線快取。
  4. 資料視覺化儀表板

    • Webpack 的 DefinePlugin 讓環境變數(如 API 金鑰)在建置時注入,避免在前端程式碼中硬編碼。
    • 使用 dynamic import() 按需載入大型貼圖或模型,提升儀表板的載入速度。

總結

  • ViteWebpack 各有千秋:Vite 以開發速度見長、設定簡潔;Webpack 則在高度客製化與大型專案上更具彈性。
  • Three.js 專案中,最重要的是 資源管理(模型、貼圖、HDR)與 渲染效能(Shader、材質、光照)能夠透過打包工具自動化處理,減少手動錯誤與維護成本。
  • 透過本文的 專案結構範例設定說明、以及 常見陷阱 的對應方案,你可以快速把一個散亂的 Three.js demo,升級為 可維護、可擴充、具備 CI/CD 流程 的工程化專案。

下一步:將此專案加入 GitHub Actions,於每次 Pull Request 時自動執行 npm run buildnpm run lint,確保所有提交的程式碼都符合最佳實踐。

祝你在 Three.js 的工程化旅程中,玩得開心、寫得順手! 🚀