本文 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. 在渲染迴圈中使用 activeScenerenderer.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 只改變 activeSceneactiveCamera,若只想渲染單一場景,只需要在 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 中必須遍歷所有子物件,釋放 geometrymaterialtexture,才能真正讓瀏覽器回收記憶體。


常見陷阱與最佳實踐

陷阱 說明 解決方式
忘記切換相機 多場景往往各自擁有不同的相機,僅切換 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.allLoadingManager 同步載入模型、貼圖,減少切換時的卡頓。

  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 內容。祝開發順利,玩得開心!