本文 AI 產出,尚未審核

Three.js 教學 – 多場景切換與群組管理


簡介

在使用 Three.js 建立 3D 應用時,單一場景 (scene) 的概念往往無法滿足複雜的需求。例如遊戲的關卡切換、產品展示的不同視角、或是需要在同一畫面中同時呈現多個獨立的子世界,都需要「多場景」的概念。

透過正確的 場景管理與群組 (Group) 機制,我們可以在不重新載入整個渲染器的情況下,快速切換、暫停或合併不同的 3D 內容,提升效能與開發效率。本文將一步步說明如何在 Three.js 中建立、切換與管理多個場景,並提供實作範例、常見陷阱與最佳實踐,幫助初學者到中級開發者快速上手。


核心概念

1. 為什麼需要多場景?

  • 資源隔離:每個場景可以擁有自己的相機、光源與物件,避免互相干擾。
  • 效能優化:只渲染當前活躍的場景,減少不必要的計算。
  • 模組化開發:可將不同功能 (例如 UI、主遊戲、背景動畫) 放在獨立的場景中,讓程式碼更易維護。

2. 基本的場景與渲染流程

// 建立渲染器、相機與單一場景(最基礎的寫法)
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

const camera = new THREE.PerspectiveCamera(
  75, window.innerWidth / window.innerHeight, 0.1, 1000
);
camera.position.set(0, 2, 5);

const scene = new THREE.Scene();
scene.add(new THREE.AxesHelper(5));

// 渲染迴圈
function animate() {
  requestAnimationFrame(animate);
  renderer.render(scene, camera);
}
animate();

上述程式碼只處理 單一場景,若要加入多場景,我們需要將渲染流程抽象化,使其能接受 任意場景與相機 作為參數。

3. 使用 THREE.Group 進行子集合管理

THREE.Group 繼承自 Object3D,可視為「容器」:

  • 群組化:一次性操作整個子集合(平移、旋轉、縮放)。
  • 層級結構:子物件會自動繼承父層的變換。
const group = new THREE.Group();
group.position.set(0, 1, 0);
scene.add(group);

// 加入多個 Mesh 到同一個 Group
const geometry = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshStandardMaterial({ color: 0x156289 });
for (let i = 0; i < 5; i++) {
  const cube = new THREE.Mesh(geometry, material);
  cube.position.set(i * 2, 0, 0);
  group.add(cube);
}

4. 多場景切換的實作步驟

  1. 建立所有場景:在程式初始化階段一次性建立(或按需動態載入)。
  2. 維護一個「當前場景」的引用:let activeScene = sceneA;
  3. 在渲染迴圈中使用 activeScene:renderer.render(activeScene, activeCamera);
  4. 切換時更新相機與控制器(若每個場景有不同的相機)。
  5. 資源釋放:不再使用的場景可呼叫 dispose() 釋放記憶體。

以下提供完整範例,示範 三個場景(主場景、UI 場景、背景場景)如何同時渲染或切換。


程式碼範例

範例 1️⃣:建立與切換三個獨立場景

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

// ---------- 建立三個場景 ----------
const sceneMain = new THREE.Scene();   // 主遊戲場景
const sceneUI   = new THREE.Scene();   // UI 介面場景(正交投影)
const sceneBG   = new THREE.Scene();   // 背景場景(星空)

// ---------- 相機 ----------
const camMain = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
camMain.position.set(0, 2, 5);
const camUI   = new THREE.OrthographicCamera(
  -window.innerWidth / 2, window.innerWidth / 2,
  window.innerHeight / 2, -window.innerHeight / 2,
  0, 10
);
const camBG   = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 500);
camBG.position.set(0, 0, 100);

// ---------- 加入簡單物件 ----------
function addDemoObjects() {
  // 主場景 - 轉動立方體
  const cubeGeo = new THREE.BoxGeometry(1, 1, 1);
  const cubeMat = new THREE.MeshStandardMaterial({ color: 0x44aa88 });
  const cube = new THREE.Mesh(cubeGeo, cubeMat);
  sceneMain.add(cube);
  cube.name = 'rotatingCube';

  // UI 場景 - 文字 Sprite(使用 Canvas)
  const canvas = document.createElement('canvas');
  const ctx = canvas.getContext('2d');
  ctx.font = '48px sans-serif';
  ctx.fillStyle = '#ff0000';
  ctx.fillText('Score: 0', 10, 50);
  const tex = new THREE.CanvasTexture(canvas);
  const spriteMat = new THREE.SpriteMaterial({ map: tex });
  const sprite = new THREE.Sprite(spriteMat);
  sprite.position.set(0, 0, 0);
  sceneUI.add(sprite);
  sprite.name = 'scoreSprite';

  // 背景場景 - 星星點點的粒子系統
  const starsGeo = new THREE.BufferGeometry();
  const positions = new Float32Array(5000 * 3);
  for (let i = 0; i < positions.length; i++) {
    positions[i] = (Math.random() - 0.5) * 200;
  }
  starsGeo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
  const starsMat = new THREE.PointsMaterial({ color: 0xffffff, size: 0.5 });
  const stars = new THREE.Points(starsGeo, starsMat);
  sceneBG.add(stars);
}
addDemoObjects();

// ---------- 控制切換 ----------
let activeScene = sceneMain;   // 起始為主場景
let activeCamera = camMain;

// 切換函式(可掛到 UI 按鈕或鍵盤事件)
function switchScene(target) {
  switch (target) {
    case 'main':
      activeScene = sceneMain;  activeCamera = camMain; break;
    case 'ui':
      activeScene = sceneUI;    activeCamera = camUI;   break;
    case 'bg':
      activeScene = sceneBG;    activeCamera = camBG;   break;
  }
}

// ---------- 渲染迴圈 ----------
function animate() {
  requestAnimationFrame(animate);

  // 主場景立方體持續旋轉
  const cube = sceneMain.getObjectByName('rotatingCube');
  if (cube) cube.rotation.y += 0.01;

  // 同時渲染背景、主場景與 UI(若想疊加效果)
  // 1. 背景(先渲染,深度清除)
  renderer.autoClear = false;               // 允許多次渲染到同一緩衝區
  renderer.clear();                         // 清除顏色與深度緩衝
  renderer.render(sceneBG, camBG);
  // 2. 主場景
  renderer.render(sceneMain, camMain);
  // 3. UI(使用正交相機,深度不影響)
  renderer.render(sceneUI, camUI);
}
animate();

// ---------- 範例:按鍵切換 ----------
window.addEventListener('keydown', (e) => {
  if (e.key === '1') switchScene('main');
  if (e.key === '2') switchScene('ui');
  if (e.key === '3') switchScene('bg');
});

說明

  • renderer.autoClear = false 讓我們可以在同一幀中依序渲染多個場景,達成「背景 + 主體 + UI」的疊加效果。
  • switchScene 只改變 activeScene 與 activeCamera,若只想渲染單一場景,只需要在 animate 中改為 renderer.render(activeScene, activeCamera);。

範例 2️⃣:使用 Group 動態切換子場景

有時候我們不想建立完整的 Scene,而是把 子集合 包在 Group 中,根據需求顯示或隱藏。

// 建立兩個子群組:城市與森林
const cityGroup = new THREE.Group();
cityGroup.name = 'city';
const forestGroup = new THREE.Group();
forestGroup.name = 'forest';

// 城市 - 簡易立方體建築
for (let i = 0; i < 10; i++) {
  const building = new THREE.Mesh(
    new THREE.BoxGeometry(1, Math.random() * 3 + 1, 1),
    new THREE.MeshLambertMaterial({ color: 0x888888 })
  );
  building.position.set(i * 2 - 10, building.geometry.parameters.height / 2, 0);
  cityGroup.add(building);
}

// 森林 - 樹木(圓柱 + 球體)
for (let i = 0; i < 15; i++) {
  const trunk = new THREE.Mesh(
    new THREE.CylinderGeometry(0.2, 0.2, 2),
    new THREE.MeshLambertMaterial({ color: 0x654321 })
  );
  const foliage = new THREE.Mesh(
    new THREE.SphereGeometry(1),
    new THREE.MeshLambertMaterial({ color: 0x228822 })
  );
  trunk.position.set(Math.random() * 20 - 10, 1, Math.random() * 20 - 10);
  foliage.position.set(trunk.position.x, 2.5, trunk.position.z);
  forestGroup.add(trunk, foliage);
}

// 加入主場景
sceneMain.add(cityGroup, forestGroup);

// 切換顯示的子場景
function showGroup(name) {
  cityGroup.visible = (name === 'city');
  forestGroup.visible = (name === 'forest');
}

// 初始僅顯示城市
showGroup('city');

// 透過 UI 按鈕切換
document.getElementById('btnCity').addEventListener('click', () => showGroup('city'));
document.getElementById('btnForest').addEventListener('click', () => showGroup('forest'));

重點:Group.visible 屬性可以一次性關閉/開啟整個子集合,省去逐一設定每個 Mesh 的顯示狀態。


範例 3️⃣:動態載入與釋放場景資源(GLTF)

在大型專案中,按需載入 可大幅降低首次載入時間。下面示範如何使用 GLTFLoader 讀取模型,並在切換後釋放資源。

import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';

let loadedScenes = {};   // 暫存已載入的子場景
const loader = new GLTFLoader();

// 載入模型並包裝成 Group
function loadGLTFScene(url, key) {
  return new Promise((resolve, reject) => {
    loader.load(
      url,
      (gltf) => {
        const group = gltf.scene;
        group.name = key;
        loadedScenes[key] = group;
        resolve(group);
      },
      undefined,
      (err) => reject(err)
    );
  });
}

// 範例:載入兩個不同的關卡模型
async function initScenes() {
  const level1 = await loadGLTFScene('models/level1.glb', 'level1');
  const level2 = await loadGLTFScene('models/level2.glb', 'level2');
  sceneMain.add(level1, level2);
  // 初始只顯示 level1
  level1.visible = true;
  level2.visible = false;
}
initScenes();

// 切換關卡
function switchLevel(to) {
  Object.values(loadedScenes).forEach(g => g.visible = false);
  if (loadedScenes[to]) loadedScenes[to].visible = true;
}

// 釋放不再使用的模型(例如離開遊戲後)
function disposeLevel(key) {
  const group = loadedScenes[key];
  if (!group) return;
  group.traverse((obj) => {
    if (obj.isMesh) {
      obj.geometry.dispose();
      if (obj.material.isMaterial) {
        obj.material.dispose();
      } else { // material 為陣列
        obj.material.forEach(m => m.dispose());
      }
    }
  });
  sceneMain.remove(group);
  delete loadedScenes[key];
}

技巧:在 disposeLevel 中必須遍歷所有子物件,釋放 geometry、material、texture,才能真正讓瀏覽器回收記憶體。


常見陷阱與最佳實踐

陷阱 說明 解決方式
忘記切換相機 多場景往往各自擁有不同的相機,僅切換 scene 而未同步 camera 會導致視角錯位或看不到物件。 在切換函式中同時更新 activeCamera,或使用 相機容器 (cameraGroup) 讓相機保持相對位置。
自動清除 (autoClear) 未關閉 渲染多個場景時,每一次 renderer.render 都會清除緩衝區,導致前面的場景被覆蓋。 設定 renderer.autoClear = false,並在每幀手動 renderer.clear()。
記憶體洩漏 反覆載入 GLTF、Texture 等資源卻未呼叫 dispose(),瀏覽器記憶體持續上升。 為每個載入的資源建立 dispose 函式,並在不需要時呼叫。
Group 內部變換未同步 修改 Group 的位置、旋轉後,子物件的世界座標仍舊以舊值計算(如使用 raycaster 時)。 使用 group.updateMatrixWorld() 強制更新,或在每次變換後呼叫 renderer.render 前更新。
渲染順序錯誤 UI 常需要在最上層渲染,若不先渲染背景、再渲染主體、最後渲染 UI,會出現 UI 被遮蔽的情況。 按 背景 → 主體 → UI 的順序呼叫 renderer.render,或使用 深度測試 控制 (depthTest: false 在 UI 材質上)。

最佳實踐

  1. 封裝渲染管理器
    建議建立一個 SceneManager 類別,負責註冊、切換與釋放場景,讓主程式保持乾淨。

  2. 使用 requestAnimationFrame 只呼叫一次
    即使有多個場景,也只需要一個全域的 animate 迴圈,裡面依需求渲染多個場景。

  3. 將 UI 放入獨立的 Scene + 正交相機
    這樣即使在 3D 空間中改變相機位置,UI 仍保持固定大小與位置。

  4. 預先載入關鍵資源
    使用 Promise.all 或 LoadingManager 同步載入模型、貼圖,減少切換時的卡頓。

  5. 保持 Group 階層盡可能淺
    深層嵌套會增加矩陣計算成本,對效能有負面影響。


實際應用場景

領域 典型需求 多場景切換的好處
遊戲 關卡、主選單、暫停畫面、HUD 可在同一渲染器內快速切換,保持音效與輸入狀態不斷線。
產品展示 不同產品、不同顏色、特寫鏡頭 每個產品作為獨立場景,切換時僅渲染目標,提高載入速度。
虛擬導覽 室內外、不同樓層、過場動畫 背景場景(天空盒)與導覽場景分離,過場時只切換相機與場景。
資料視覺化 多層次圖表、時間軸切換 把每個時間點的圖表放入不同場景,使用滑桿切換,保持動畫流暢。
AR/VR 多層 UI、沉浸式環境切換 UI 以正交場景渲染,沉浸環境以透視場景渲染,兩者同步顯示。

總結

在 Three.js 中,多場景切換 不只是技術挑戰,更是一種設計思維:把不同功能、不同資源、不同渲染需求分離,讓程式碼更具模組化、效能更佳。本文從 為什麼需要多場景、Group 的運用、到 實作範例、常見陷阱 與 最佳實踐,一步步說明如何在專案中導入多場景管理。

掌握以下關鍵點,即可在自己的 Three.js 專案裡自由切換、疊加或暫停任意場景:

  1. 維護 activeScene / activeCamera,確保渲染的正確目標。
  2. 使用 renderer.autoClear = false 讓多場景渲染不互相覆蓋。
  3. 善用 THREE.Group 進行子集合的批次操作與顯示/隱藏。
  4. 按需載入與正確釋放資源,避免記憶體洩漏。
  5. 封裝管理器,讓切換邏輯保持在單一位置,提升可維護性。

只要依照本文的步驟與建議實作,你就能在 Three.js 中建立流暢、可擴充的多場景體驗,為使用者帶來更豐富、更互動的 3D 內容。祝開發順利,玩得開心!