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. 多場景切換的實作步驟
- 建立所有場景:在程式初始化階段一次性建立(或按需動態載入)。
- 維護一個「當前場景」的引用:
let activeScene = sceneA; - 在渲染迴圈中使用
activeScene:renderer.render(activeScene, activeCamera); - 切換時更新相機與控制器(若每個場景有不同的相機)。
- 資源釋放:不再使用的場景可呼叫
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 材質上)。 |
最佳實踐
封裝渲染管理器
建議建立一個SceneManager類別,負責註冊、切換與釋放場景,讓主程式保持乾淨。使用
requestAnimationFrame只呼叫一次
即使有多個場景,也只需要一個全域的animate迴圈,裡面依需求渲染多個場景。將 UI 放入獨立的
Scene+ 正交相機
這樣即使在 3D 空間中改變相機位置,UI 仍保持固定大小與位置。預先載入關鍵資源
使用Promise.all或LoadingManager同步載入模型、貼圖,減少切換時的卡頓。保持 Group 階層盡可能淺
深層嵌套會增加矩陣計算成本,對效能有負面影響。
實際應用場景
| 領域 | 典型需求 | 多場景切換的好處 |
|---|---|---|
| 遊戲 | 關卡、主選單、暫停畫面、HUD | 可在同一渲染器內快速切換,保持音效與輸入狀態不斷線。 |
| 產品展示 | 不同產品、不同顏色、特寫鏡頭 | 每個產品作為獨立場景,切換時僅渲染目標,提高載入速度。 |
| 虛擬導覽 | 室內外、不同樓層、過場動畫 | 背景場景(天空盒)與導覽場景分離,過場時只切換相機與場景。 |
| 資料視覺化 | 多層次圖表、時間軸切換 | 把每個時間點的圖表放入不同場景,使用滑桿切換,保持動畫流暢。 |
| AR/VR | 多層 UI、沉浸式環境切換 | UI 以正交場景渲染,沉浸環境以透視場景渲染,兩者同步顯示。 |
總結
在 Three.js 中,多場景切換 不只是技術挑戰,更是一種設計思維:把不同功能、不同資源、不同渲染需求分離,讓程式碼更具模組化、效能更佳。本文從 為什麼需要多場景、Group 的運用、到 實作範例、常見陷阱 與 最佳實踐,一步步說明如何在專案中導入多場景管理。
掌握以下關鍵點,即可在自己的 Three.js 專案裡自由切換、疊加或暫停任意場景:
- 維護
activeScene/activeCamera,確保渲染的正確目標。 - 使用
renderer.autoClear = false讓多場景渲染不互相覆蓋。 - 善用
THREE.Group進行子集合的批次操作與顯示/隱藏。 - 按需載入與正確釋放資源,避免記憶體洩漏。
- 封裝管理器,讓切換邏輯保持在單一位置,提升可維護性。
只要依照本文的步驟與建議實作,你就能在 Three.js 中建立流暢、可擴充的多場景體驗,為使用者帶來更豐富、更互動的 3D 內容。祝開發順利,玩得開心!